Compare commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:37:33 +03:00
Leonid Pershin 9d1c6d2fc3 Enhance code readability and maintainability by refactoring various components. Update formatting commands in CLAUDE.md for csharpier integration. Simplify method signatures in SegmentFiles, UploadLimits, and multiple endpoint classes for improved clarity. Adjust query handlers and command handlers to streamline parameter handling and enhance consistency across the application.
ci / build-backend (push) Successful in 2m8s
ci / build-frontend (push) Failing after 32s
ci / tests (push) Skipped
ci / sonar (push) Skipped
2026-07-27 01:20:55 +03:00
Leonid Pershin 5449c05b5b Refactor components to enhance code clarity and maintainability by updating prop types to use Readonly for better immutability. Adjust Result class methods for consistency, and streamline query functions in ChannelDetail and other components to improve performance and readability.
ci / build-backend (push) Successful in 1m41s
ci / build-frontend (push) Successful in 47s
ci / sonar (push) Successful in 5m35s
ci / tests (push) Successful in 2m40s
2026-07-27 01:14:53 +03:00
Leonid Pershin ecb5417170 Update README.md to change quality gate badge URL for improved accuracy in project status reporting.
ci / build-backend (push) Successful in 2m6s
ci / build-frontend (push) Successful in 48s
ci / tests (push) Successful in 2m3s
ci / sonar (push) Successful in 5m41s
2026-07-27 01:08:20 +03:00
Leonid Pershin 19fa23b619 Refactor various components to improve code clarity and maintainability. Update ListUsersQueryHandler to utilize UserListFilter for parameter handling. Refactor BumperSpecFactory and related classes to encapsulate input parameters into dedicated records, enhancing readability. Adjust MediaAsset and Slot classes to streamline content updates with new content models. Improve BumperRenderBackgroundService and MediaProcessingBackgroundService to use MediaReadyInfo for asset readiness, ensuring consistent parameter management across the application.
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 6m28s
ci / build-backend (push) Successful in 1m55s
ci / build-frontend (push) Successful in 45s
2026-07-27 00:56:04 +03:00
Leonid Pershin 419aff54fa Enhance EntryTraceDialog component by refactoring data display logic into dedicated summary functions for improved readability and maintainability. Update GridTab to streamline checkbox state management with a new toggle function. Refactor RulesCard to simplify window removal logic. Adjust CollectionsPanel, GenresPanel, GroupsPanel, RolesPanel, ShowsPanel, and UsersPanel to import sorting utilities from a centralized location, enhancing code organization. Update ThemeProvider to utilize a shared theme context for better consistency across the application.
ci / sonar (push) Successful in 6m13s
ci / build-backend (push) Successful in 2m15s
ci / build-frontend (push) Successful in 50s
ci / tests (push) Successful in 2m34s
2026-07-27 00:25:11 +03:00
Leonid Pershin f4926848d7 Remove ToastProvider and related toast context functionality to streamline UI components and eliminate unused code. This change enhances overall code maintainability and reduces bundle size. 2026-07-27 00:25:03 +03:00
Leonid Pershin 38b6160bd2 Refactor JunctionFiller and SchedulePlanner to improve parameter handling by introducing JunctionPlacement record for cleaner code and better readability. Update Directory.Build.props to suppress additional warnings related to API boundaries and unused route parameters, ensuring a more focused analysis during builds.
ci / build-backend (push) Successful in 1m10s
ci / build-frontend (push) Successful in 37s
ci / tests (push) Successful in 1m31s
ci / sonar (push) Successful in 3m26s
2026-07-27 00:01:22 +03:00
Leonid Pershin 8699d8ee00 Refactor LayerApplicabilityDialog and RulesCard components to utilize useKeyedList for managing lists, improving state handling and performance. Update ScheduleGrid to enhance key assignment for mapped elements, ensuring better React reconciliation. Modify ManualInboxDialog to use unique keys for parts in sample name processing, and improve ToastProvider and ThemeProvider by memoizing context values to prevent unnecessary re-renders.
ci / build-backend (push) Successful in 1m15s
ci / build-frontend (push) Failing after 15s
ci / tests (push) Skipped
ci / sonar (push) Skipped
2026-07-26 23:54:30 +03:00
Leonid Pershin 940b622c03 Refactor various classes to initialize lists with empty array syntax for improved clarity and consistency. Update Program.cs to remove unnecessary partial class declaration due to changes in ASP.NET Core 10. Enhance MediaPathResolver by making AssetRelativePath static for better accessibility. Adjust MediaClaimingBackgroundService to centralize idle polling logic. Improve SignalQueue's WaitAsync method for clearer intent in signal draining.
ci / build-backend (push) Successful in 1m46s
ci / sonar (push) Successful in 5m1s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 1m38s
2026-07-26 23:36:55 +03:00
Leonid Pershin 59cc453026 Refactor RenderBumperPreviewCommandHandler to streamline background path resolution and rendering specification. Introduce helper methods for improved readability and maintainability. Update FindMissingEpisodesQueryHandler and RefreshShowEpisodesMetadataCommandHandler to encapsulate episode number resolution logic. Enhance CopyTemplateCommandHandler by modularizing junction copying and slot handling for better clarity. Improve FfmpegMediaProcessor by separating duration and stream parsing into dedicated methods. Refactor MediaClaimingBackgroundService to simplify idle waiting logic. Update GenreSeeder to modularize alias addition logic, enhancing code organization.
ci / build-backend (push) Successful in 1m29s
ci / build-frontend (push) Successful in 1m4s
ci / tests (push) Successful in 1m58s
ci / sonar (push) Successful in 3m34s
2026-07-26 23:31:56 +03:00
Leonid Pershin 69d1319564 Add UploadLimits service and refactor user and media listing endpoints to use filter records for improved parameter handling. Update audio upload logic to streamline parameter passing and enhance clarity. Adjust ImageEndpoints to use HashSet for allowed extensions, and refine StreamingEndpoints to clarify token handling.
ci / build-backend (push) Successful in 1m29s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 2m1s
ci / sonar (push) Successful in 4m22s
2026-07-26 23:05:37 +03:00
Leonid Pershin 0f0657e523 Refactor Program.cs to use async Run method for improved performance. Update index.html to specify language attribute for accessibility. Enhance BumperTemplateEditor and ScheduleGrid components by replacing divs with buttons for better keyboard accessibility. Improve TemplatePreview sorting logic and optimize episode regex handling. Add close button to ToastItem for better user interaction. Update localization files to include 'close' translations. Refactor CardTitle to ensure screen reader compatibility by explicitly rendering children.
ci / tests (push) Successful in 1m58s
ci / build-backend (push) Successful in 1m34s
ci / build-frontend (push) Successful in 1m0s
ci / sonar (push) Successful in 4m13s
2026-07-26 22:35:36 +03:00
Leonid Pershin b0740503bb Enhance metadata handling by introducing ShowKind parameter across various components. Update SearchShowMetadataQuery and related handlers to support querying by show type, ensuring accurate metadata retrieval for series and films. Implement validation to prevent episodes from being associated with non-series shows. Update frontend API to include ShowKind in metadata search requests, improving overall functionality and user experience.
ci / build-backend (push) Successful in 3m42s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 2m10s
ci / sonar (push) Successful in 6m40s
2026-07-26 22:29:16 +03:00
Leonid Pershin fdb0f321e4 Refactor ShowAudience handling across the application to align with MPAA rating system. Update ShowAudience enum to reflect new ratings (G, PG, PG-13, R, NC-17) and adjust related data models, API types, and frontend components to support nullable audience values. Enhance metadata handling to incorporate content ratings from external sources, ensuring proper audience filtering in scheduling logic. Update documentation to clarify changes in audience categorization and its implications for content management.
ci / sonar (push) Successful in 4m31s
ci / build-backend (push) Successful in 1m28s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 2m7s
2026-07-26 22:18:14 +03:00
Leonid Pershin c4c93cff02 Enhance query performance by applying AsSplitQuery to multiple handlers and loaders for Channels and ScheduleTemplates, improving efficiency in data retrieval and reducing unnecessary data duplication in queries.
ci / build-frontend (push) Successful in 49s
ci / build-backend (push) Successful in 1m26s
ci / sonar (push) Failing after 2m31s
ci / tests (push) Successful in 4m18s
2026-07-26 21:50:01 +03:00
Leonid Pershin b52080e026 Refactor Dockerfile and documentation for non-root user setup. Update CLAUDE.md and README.md to clarify media storage permissions and user requirements. Modify appsettings for local development database configuration. Enhance MediaEndpoints by removing unused regex and improving file resolution logic. Update BumperPreview to use SHA-256 for asset ID generation, ensuring better security practices.
ci / tests (push) Successful in 1m52s
ci / build-backend (push) Successful in 1m33s
ci / build-frontend (push) Successful in 1m2s
ci / sonar (push) Successful in 4m24s
2026-07-26 21:41:33 +03:00
Leonid Pershin e79769be81 Update .env.example to extend scheduler configuration: increase HorizonDays to 7 and change RetentionHours to RetentionDays with a default of 90. Revise documentation in media-storage-and-streaming.md to reflect these changes. Remove outdated tv-scheduler-tasks.md file.
ci / build-backend (push) Successful in 1m11s
ci / tests (push) Successful in 1m35s
ci / build-frontend (push) Successful in 36s
ci / sonar (push) Successful in 6m25s
2026-07-26 21:27:31 +03:00
Leonid Pershin 34701bd6b1 Update README.md to replace build and test badges with a consolidated CI badge. Remove obsolete workflow files for build, test, and Sonar analysis, streamlining the CI/CD process.
ci / tests (push) Successful in 1m32s
ci / build-backend (push) Successful in 1m10s
ci / build-frontend (push) Successful in 48s
ci / sonar (push) Successful in 3m17s
2026-07-26 20:52:37 +03:00
Leonid Pershin 205672b77d Refactor .gitignore to streamline ignored files and enhance clarity. Update CLAUDE.md to improve unit test instructions and add coverage reporting details. Revise README.md for better project overview and deployment instructions. Refactor ChannelEndpoints and StreamingEndpoints to utilize SegmentFiles for file resolution, improving code maintainability. Remove unused JunctionHandlers and update DependencyInjection for cleaner service registration. Enhance media processing services for better job handling and error management. Update frontend API types for consistency and clarity.
build / backend (push) Successful in 1m28s
build / frontend (push) Failing after 31s
sonar / analyze (push) Successful in 4m39s
tests / backend-tests (push) Canceled after 0s
2026-07-26 20:43:38 +03:00
Leonid Pershin f36dbfa9cb Refactor ChannelDetail component to improve template copying functionality and update translations. Change variable names for clarity, ensuring the source channel is correctly referenced when copying templates. Enhance user confirmation prompts and update related UI elements for better user experience.
build / backend (push) Successful in 1m10s
build / frontend (push) Successful in 34s
tests / backend-tests (push) Successful in 3m42s
2026-07-26 20:17:45 +03:00
Leonid Pershin 9c865e1faa Add CreateTemplate endpoint and related functionality for channels without grids
build / backend (push) Successful in 3m23s
build / frontend (push) Successful in 34s
tests / backend-tests (push) Successful in 1m38s
Implement a new API endpoint to create a template for channels lacking a grid, enhancing the template management capabilities. Update frontend components to support the creation of templates directly from the channel detail view, including user feedback and translations for improved clarity. Add integration tests to ensure the new functionality works as expected.
2026-07-26 20:10:47 +03:00
Leonid Pershin 69c236d8cc Refactor ChannelDetail component to implement tabbed navigation for channel settings, enhancing user experience by organizing content into distinct sections. Introduce a new TABS constant for better maintainability and update related components (BumperCard, RulesCard, ViewerCard, etc.) to support a 'bare' prop for streamlined rendering. Improve error handling for template loading and update translations for better clarity.
build / backend (push) Successful in 1m10s
build / frontend (push) Successful in 35s
tests / backend-tests (push) Successful in 1m31s
2026-07-26 20:03:14 +03:00
Leonid Pershin 2387223f0b Refactor AdminUserEndpoints and MediaEndpoints to include default parameter values for pagination and filtering, improving API usability. Update ManualInboxDialog to enhance show selection logic with auto-detection based on file names, and improve user feedback with new translations. Refactor PostgresFixture for better container management in integration tests.
build / backend (push) Successful in 2m26s
build / frontend (push) Successful in 1m5s
tests / backend-tests (push) Successful in 2m27s
2026-07-26 16:17:24 +03:00
Leonid Pershin 8fa4ea02fe Implement automatic media list refresh in MediaPanel: add activity tracking for queued and processing items, ensuring the media list updates when statuses change. Enhance query handling with refetch logic to improve user experience in media management.
build / backend (push) Successful in 3m38s
build / frontend (push) Successful in 1m6s
tests / backend-tests (push) Successful in 3m3s
2026-07-26 16:06:02 +03:00
Leonid Pershin b5a47c6d8a Enhance ManualInboxDialog: add show selection dropdown for improved media organization and update translations for better user guidance. Simplify layout by removing redundant elements while maintaining user hints.
tests / backend-tests (push) Successful in 1m57s
build / backend (push) Successful in 1m29s
build / frontend (push) Successful in 53s
2026-07-26 15:55:06 +03:00
Leonid Pershin 6bca42754c Update validation rules in ImportManualInboxCommand and ManualInboxDialog to allow season numbers starting from zero, reflecting the inclusion of special episodes and pilots. Adjust input constraints in the frontend to enhance user experience and ensure consistency with backend validation.
tests / backend-tests (push) Successful in 2m2s
build / backend (push) Successful in 1m48s
build / frontend (push) Successful in 51s
2026-07-26 15:53:09 +03:00
Leonid Pershin 7af6242003 Enhance manual inbox dialog: add regex presets and hints for episode number extraction, improve user interface for file selection, and update translations for better user guidance. Refactor state management and query handling to streamline the import process and enhance overall user experience in manual media management.
build / backend (push) Successful in 1m12s
build / frontend (push) Successful in 46s
tests / backend-tests (push) Successful in 4m19s
2026-07-26 15:48:41 +03:00
Leonid Pershin a95c0540d9 Refactor manual inbox handling: update ImportManualInboxCommand to accept ManualImportItem objects, enhancing file import functionality with explicit season and episode numbers. Implement CleanupManualLeftoversAsync in IMediaStorage to remove unnecessary files after import. Update frontend components to support new import structure and improve user experience in manual media management.
tests / backend-tests (push) Successful in 1m38s
build / backend (push) Successful in 1m11s
build / frontend (push) Successful in 34s
2026-07-26 15:41:31 +03:00
Leonid Pershin 74bca20a3c Refactor user settings access in navigation: replace direct settings link with username display for logged-in users, enhancing mobile and desktop menu experiences. Update logout button placement and styling for improved usability.
tests / backend-tests (push) Successful in 1m29s
build / backend (push) Successful in 1m14s
build / frontend (push) Successful in 45s
2026-07-26 15:25:18 +03:00
Leonid Pershin b602d099ca Refactor media storage and management functionalities: enhance IMediaStorage interface with manual inbox handling, update FileSystemMediaStorage to support manual file imports, and improve MediaPathResolver for better path management. Extend MediaEndpoints to include new manual inbox features and update frontend components for improved media management experience.
build / backend (push) Successful in 2m8s
build / frontend (push) Successful in 36s
tests / backend-tests (push) Successful in 1m35s
2026-07-26 15:16:55 +03:00
Leonid Pershin 2445ba56b5 Enhance scheduling and trace functionalities: add CollectionId to ScheduleEntry and PlannedItem models, update related query handlers and frontend components to support collection information in entry traces. Improve data handling in scheduling logic and enhance user experience in trace display.
build / backend (push) Successful in 2m4s
build / frontend (push) Successful in 39s
tests / backend-tests (push) Successful in 2m10s
2026-07-26 14:59:54 +03:00
Leonid Pershin f699576582 Enhance channel and settings functionalities: introduce viewer settings in ChannelEndpoints, update SiteSettings to include channel number toggling, and refactor related data structures. Implement new endpoints for validating templates and diffing scheduling changes, improving overall user experience and configuration management.
build / backend (push) Successful in 1m54s
build / frontend (push) Successful in 43s
tests / backend-tests (push) Successful in 2m4s
2026-07-26 14:50:32 +03:00
Leonid Pershin 8494eb5e6a Enhance template and scheduling functionalities: add PlanningRules to template endpoints and commands, implement repeat limits and audience filtering in scheduling logic, and update related data structures. Refactor frontend components to support new rules and improve user experience in channel management. 2026-07-26 14:25:32 +03:00
Leonid Pershin 7b12a06d1b Add interstitial endpoints and enhance media and template functionalities: implement MapInterstitialEndpoints in Program.cs, add preview functionality for media assets in MediaEndpoints, and introduce template preview capabilities in TemplateEndpoints. Remove obsolete bumper settings from Channel and related classes to streamline configuration.
build / frontend (push) Successful in 47s
tests / backend-tests (push) Successful in 5m57s
build / backend (push) Successful in 5m58s
2026-07-26 14:03:53 +03:00
Leonid Pershin 66040a8841 Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s
2026-07-26 13:32:13 +03:00
Leonid Pershin c4ef954dea Update TV scheduler architecture documentation: expand on data model details, including channel, pool, grid layer, and slot structures. Enhance descriptions of scheduling strategies and constraints, and clarify the role of epoch and timezone in scheduling. Improve overall clarity and completeness of the specification.
build / backend (push) Successful in 1m11s
build / frontend (push) Successful in 31s
tests / backend-tests (push) Successful in 1m23s
2026-07-26 04:18:33 +03:00
Leonid Pershin a0533a8ed8 Refactor user and media endpoints: remove unused GetUser and GetMedia methods, streamline AdminUserEndpoints and MediaEndpoints, and enhance metadata handling in MetadataEndpoints. Update Program.cs to remove legacy image relocation logic and improve overall code clarity.
build / backend (push) Successful in 1m14s
build / frontend (push) Successful in 35s
tests / backend-tests (push) Successful in 3m16s
2026-07-26 02:58:17 +03:00
Leonid Pershin 44e954920c Add missing episodes feature: implement FindMissingEpisodes endpoint, update metadata providers to retrieve season episode counts, and enhance frontend components for displaying missing episodes report. Update translations for new UI elements.
build / frontend (push) Successful in 44s
tests / backend-tests (push) Successful in 2m38s
build / backend (push) Successful in 3m5s
2026-07-26 02:15:47 +03:00
Leonid Pershin 8962840654 Add audience categorization for shows: implement ShowAudience type, update related API endpoints, and enhance frontend components for audience selection and display. Modify data structures to support audience information in show creation and retrieval processes.
build / backend (push) Successful in 3m35s
build / frontend (push) Successful in 52s
tests / backend-tests (push) Successful in 3m27s
2026-07-26 02:06:08 +03:00
Leonid Pershin 525fd2ee01 Implement preferred audio languages feature: update site settings to include preferred audio languages, modify related data structures and API endpoints, and enhance media processing to select audio tracks based on user preferences. Update frontend components for managing and displaying these settings.
tests / backend-tests (push) Failing after 32s
build / backend (push) Successful in 2m17s
build / frontend (push) Successful in 58s
2026-07-26 01:51:55 +03:00
Leonid Pershin 64a2dade74 Enhance user and media management with sorting and statistics: add sorting options to user and media listing endpoints, implement media statistics retrieval, and update frontend components for sorting and displaying media processing times. Refactor related query handlers and API types to support new features.
build / backend (push) Successful in 1m35s
build / frontend (push) Successful in 31s
tests / backend-tests (push) Successful in 1m48s
2026-07-26 00:28:22 +03:00
Leonid Pershin d6ad8e11a4 Implement asynchronous bumper rendering and add integration tests: refactor BumperAsset to support async rendering, introduce BumperRenderBackgroundService for processing, and create TeleWave.Integration.Tests with Testcontainers-Postgres for comprehensive testing of scheduling and bumper generation scenarios. Update documentation and ensure tests skip if Docker is not available. 2026-07-25 23:42:40 +03:00
Leonid Pershin 6c18a9da79 Update configuration and enhance media processing: add stream token TTL and timeout settings in .env.example, improve error handling in media endpoints, and refactor command handlers for asynchronous operations. Update documentation to reflect current application state and features.
tests / backend-tests (push) Successful in 2m27s
build / backend (push) Successful in 2m19s
build / frontend (push) Successful in 53s
2026-07-25 23:14:55 +03:00
Leonid Pershin a261e261f0 Add RenderBumperPreviewCommandHandler: implement command handler for rendering bumper previews, including channel and template validation, audio duration handling, and rendering logic for bumper variants. 2026-07-25 23:14:46 +03:00
Leonid Pershin 058cbc6994 Refactor authentication and streaming cookie handling: implement secure cookie logic based on environment in AuthEndpoints and StreamingEndpoints, enhance rate limiting policy in Program.cs, and update logging configuration in appsettings.json. Fix validation behavior to use asynchronous validation methods and improve error handling in frontend components.
build / backend (push) Successful in 1m52s
build / frontend (push) Successful in 1m0s
tests / backend-tests (push) Successful in 2m27s
2026-07-25 21:18:31 +03:00
Leonid Pershin 53e0eeb776 Enhance JWT signing key validation and improve FfmpegBumperRenderer label handling: add checks for signing key length and placeholder values in DependencyInjection, and refactor label processing to read from files in FfmpegBumperRenderer. Update MediaProcessingBackgroundService to ensure slot release on claim failure.
build / backend (push) Successful in 2m24s
build / frontend (push) Successful in 54s
tests / backend-tests (push) Successful in 2m40s
2026-07-25 21:07:38 +03:00
Leonid Pershin 5bc6e144d2 Refactor ChannelEndpoints and ScheduleGenerator: consolidate endpoint logic into partial files, remove unused methods, and enhance dependency injection for scheduling. Update ChannelDetail component to streamline imports and improve UI structure.
build / backend (push) Successful in 2m17s
build / frontend (push) Successful in 52s
tests / backend-tests (push) Successful in 2m38s
2026-07-25 20:53:52 +03:00
Leonid Pershin 8484587313 Implement pagination for candidates and episodes in ShowDetail component: add Pager component for both lists, update state management for page navigation, and ensure safe page handling during filtering and updates.
tests / backend-tests (push) Successful in 2m10s
build / backend (push) Successful in 1m52s
build / frontend (push) Successful in 53s
2026-07-25 20:33:44 +03:00
Leonid Pershin 7694ff3388 Update CI workflows to use setup actions: replace container definitions with setup-dotnet and setup-node actions for backend and frontend jobs, ensuring consistent environment configuration for builds and tests.
build / backend (push) Successful in 2m11s
build / frontend (push) Successful in 49s
tests / backend-tests (push) Successful in 2m31s
2026-07-25 20:22:48 +03:00
Leonid Pershin 2165bfc459 Add coverage badge to README and update test workflow: include coverage badge in README for CI visibility and enhance test workflow to generate coverage reports and update the badge on main branch pushes.
build / backend (push) Failing after 5s
build / frontend (push) Successful in 52s
tests / backend-tests (push) Failing after 5s
2026-07-25 20:20:17 +03:00
Leonid Pershin 0fd9ab9bc2 Update README and package.json: add build and test badges to README for CI visibility, and specify pnpm as the package manager in package.json for consistency in dependency management.
build / backend (push) Failing after 1m28s
build / frontend (push) Successful in 1m39s
tests / backend-tests (push) Failing after 5s
2026-07-25 20:17:30 +03:00
Leonid Pershin 4611f90d11 Add project reference for TeleWave.Infrastructure: include TeleWave.Infrastructure.csproj in TeleWave.Application.Tests project to support additional testing capabilities. 2026-07-25 20:12:04 +03:00
Leonid Pershin ed240ef73c Add coverlet.collector package for test coverage: include in Directory.Packages.props and add references in TeleWave.Application.Tests and TeleWave.Domain.Tests projects for enhanced test reporting. 2026-07-25 19:58:56 +03:00
Leonid Pershin f57b7503ed Refactor code formatting and improve readability: standardize line breaks and indentation across various endpoint files, enhancing code clarity and maintainability. Update package version formatting in Directory.Packages.props for consistency. 2026-07-25 19:55:01 +03:00
Leonid Pershin 1f6fa6f1ae Add password reset functionality for admin users: implement ResetPassword endpoint, update AdminUserEndpoints to include password reset logic, and enhance user interface for password management. Update translations for new features and ensure proper error handling in the reset process. 2026-07-25 19:49:08 +03:00
Leonid Pershin d44aa44d95 Update UploadToShowDialog UI: increase dialog content width for better layout and adjust select trigger width for improved usability. 2026-07-25 19:27:00 +03:00
Leonid Pershin 7c1b807e5e Enhance UploadToShowDialog: add 'apply to all' functionality for batch show assignment, improve UI layout for better user experience, and update translations for new options. 2026-07-25 19:17:00 +03:00
Leonid Pershin da0ac79079 Update media processing options: enhance .env.example and MediaOptions.cs to include MaxParallelTranscodes for improved transcoding performance. Refactor MediaProcessingBackgroundService to utilize parallel processing slots, ensuring efficient handling of multiple media assets simultaneously. Update comments for clarity on new configurations and processing logic. 2026-07-25 19:01:46 +03:00
Leonid Pershin 9cefc6a198 Enhance show management: add OriginalName property to ShowSummaryDto, update ListShowsQueryHandler to include OriginalName, and improve upload functionality by allowing auto-detection of shows based on file names. Update translations for better user guidance on show detection. 2026-07-25 18:54:43 +03:00
Leonid Pershin 8718e8b6bf Enhance channel management: add support for preferred weight multipliers and preferred hours in channel shows, update related data models and API endpoints, and implement validation for new properties. Refactor scheduling logic to utilize preferred hours for weight adjustments during show planning. 2026-07-25 18:44:28 +03:00
Leonid Pershin 358adbff21 Enhance FfmpegBumperRenderer: introduce text width adjustment for dynamic font sizing, ensuring proper fitting of free text and titles within the specified width. Add FitSize method to calculate optimal font size based on text length and maximum width constraints. 2026-07-25 13:39:11 +03:00
Leonid Pershin 18325fdfcc Refactor bumper preview functionality: update API endpoints to support rendering previews for all bumper sub-blocks, modify related query and handler to generate individual asset previews, and enhance frontend components for improved user experience with variant-specific previews. Update translations for consistency in terminology. 2026-07-25 13:31:38 +03:00
Leonid Pershin a65bcf4258 Add bumper variant management: implement API endpoints for adding, updating, and removing bumper text variants, enhance data models to support variant details, and update scheduling logic to utilize variants. Refactor related components for improved bumper template handling and ensure proper error management for variant operations. 2026-07-25 13:24:11 +03:00
Leonid Pershin f640af1fc4 Enhance BumperTemplateEditor UI: add collapsible functionality for template details, improve user interaction with click-to-toggle feature, and ensure delete action does not trigger unintended collapses. Update state management for better control of template visibility. 2026-07-25 12:55:40 +03:00
Leonid Pershin a34d929330 Add rename show functionality: implement API endpoint for renaming shows, update Show model to support name changes, and enhance ShowMetadataCard UI for name input. Update translations for new UI elements to improve user experience. 2026-07-25 12:19:07 +03:00
Leonid Pershin 671bbb78bd Enhance ShowMetadataCard UI: update button labels for gallery selection, improve metadata search input with a dedicated label, and streamline provider selection functionality. Update translations for new UI elements to improve user experience. 2026-07-25 12:11:29 +03:00
Leonid Pershin afe6cc24aa Refactor ShowMetadataCard and API: remove obsolete poster upload functionality, streamline metadata saving process into a single mutation, and enhance UI for original name input. Update related state management and error handling for improved user experience. 2026-07-25 12:03:25 +03:00
Leonid Pershin 7efd616c29 Refactor bumper template background management: rename background upload endpoint to reflect new functionality, update related command and handler to use image ID instead of extension, and adjust data models to support image references. Remove obsolete background handling code and enhance UI components for improved image selection and display. 2026-07-25 11:56:36 +03:00
Leonid Pershin a970eae7d2 Implement image management enhancements: add functionality to relocate legacy images during startup, update image handling in show metadata, and refactor related API endpoints to utilize the new image storage system. Adjust database schema to support image references and update UI components for improved image selection and display. 2026-07-25 11:43:17 +03:00
Leonid Pershin 149cd153b9 Add image management functionality: introduce Image entity and related API endpoints, update database schema to support image storage, and enhance UI with a new gallery feature for image selection and upload. Update translations for gallery-related terms. 2026-07-25 11:27:34 +03:00
Leonid Pershin 72451f89a8 Add original name support for shows: implement API endpoint to set original name, update Show and ShowDto models to include original name field, and enhance UI components for managing original names in show metadata. Update validation and database schema accordingly. 2026-07-25 11:10:33 +03:00
Leonid Pershin 84c2867062 Refactor Channel endpoints and data models: replace jingle functionality with bumper templates, update related commands and handlers, and enhance API routes for managing bumper templates. Remove obsolete jingle-related code and adjust channel data structures to support new bumper template features. 2026-07-25 11:02:16 +03:00
Leonid Pershin 27571a4ab6 Refactor ChannelDetail component: replace Card components with CollapsibleCard for improved UI organization and user experience. Introduce CollapsibleCard functionality to manage expandable content sections. Update AirPage to enhance time display formatting for better readability. 2026-07-25 10:10:36 +03:00
Leonid Pershin 9a17715d0f Refactor ChannelPlayer component: remove play/pause functionality and related state management, update comments for clarity, and streamline audio control features for a live streaming experience. 2026-07-25 10:03:11 +03:00
Leonid Pershin 8ca585a5be Enhance metadata handling and UI feedback: add support for jingles in channel queries, implement error handling for metadata provider requests, and improve search functionality in ShowMetadataCard with user feedback for no results found. Update translations for new UI messages. 2026-07-25 09:58:59 +03:00
Leonid Pershin 7fb46b5e0d Implement episode metadata management: add functionality to refresh episode metadata from external sources, including new API endpoints and UI integration. Enhance Show and Episode models to support additional metadata fields, and update database schema accordingly. Update ShowDetail and ShowMetadataCard components to display refreshed episode information and provide user feedback on metadata updates. 2026-07-25 09:46:19 +03:00
Leonid Pershin 0d2dee815e Add metadata management for shows: implement metadata retrieval, application, and updates in the API and UI. Enhance Show and ShowDto models to include metadata fields, and update the database schema accordingly. Introduce new endpoints for metadata operations and integrate metadata display in the ShowDetail component. 2026-07-25 09:07:55 +03:00
Leonid Pershin ba023bc416 Enhance upload functionality in UploadStore and UI: implement retry mechanism for failed uploads, allowing users to manually retry uploads from the UploadSnackbar. Update translations to include retry options and improve error handling for transient issues during media uploads. 2026-07-25 08:38:12 +03:00
Leonid Pershin de7e27c80f Refactor ShowDetail component to utilize parseEpisodeName for episode parsing: replace legacy parsing logic with a new function that supports additional episode naming formats. Update sorting and display logic to reflect parsed episode data, enhancing the overall user experience. 2026-07-25 08:23:59 +03:00
Leonid Pershin 2dd8bf9724 Add upload to show functionality in MediaPanel: introduce file selection for uploading media files directly to a show, enhancing the upload store to support show associations. Update translations for new upload options and UI elements. 2026-07-25 08:16:05 +03:00
Leonid Pershin 4bebe64ff0 Enhance media upload functionality: add abort signal support for uploadMedia, enabling cancellation of ongoing uploads. Implement cancel and cancelAll methods in upload store to manage queued and active uploads. Update UploadSnackbar to include cancel buttons for individual and all uploads. Enhance i18n with new cancellation strings. 2026-07-25 08:06:54 +03:00
Leonid Pershin ca907762aa Add user creation functionality: implement API endpoint for creating new users, including request handling and response management. Update frontend to support user creation with a new form in the UsersPanel, integrating role selection and input validation. Enhance i18n for new user-related strings. 2026-07-25 07:54:51 +03:00
Leonid Pershin c53848477f Add settings endpoints and registration status check: implement new API endpoints for settings management, including registration status retrieval, and update the registration command handler to enforce registration rules based on site settings. 2026-07-25 07:48:17 +03:00
Leonid Pershin 1bdc3323ab Update ChannelDetail component: adjust SelectTrigger width for better UI consistency and accommodate longer text without wrapping. 2026-07-25 06:57:21 +03:00
Leonid Pershin 4eee98467d Implement audio settings persistence in ChannelPlayer: add functionality to read and store volume and mute state in localStorage, ensuring user preferences are retained across sessions. Update playback logic to respect saved audio settings and handle autoplay restrictions gracefully. 2026-07-25 06:48:37 +03:00
Leonid Pershin 19013a61dc Implement control visibility logic in ChannelPlayer: add functionality to hide and reveal controls based on user interaction, enhancing the user experience during video playback. This includes managing visibility on mouse movements and video play/pause events. 2026-07-25 06:43:19 +03:00
Leonid Pershin 0ac2973f46 Add double-click functionality to ChannelPlayer for fullscreen toggle: enhance user interaction by allowing users to enter fullscreen mode with a double-click on the player. 2026-07-25 06:39:58 +03:00
Leonid Pershin 041df061f9 Enhance ChannelPlayer component: add volume control functionality with a slider, update mute logic to respect last volume level, and integrate volume translations for improved user experience. 2026-07-25 01:03:36 +03:00
Leonid Pershin 5ad2746ddb Refactor ShowDetail component to display loaded seasons: update season counting logic to return an array of loaded seasons, enhance UI to show the number of loaded seasons and their details, and update translations for new season information. 2026-07-25 00:54:00 +03:00
Leonid Pershin 17fdf32a3c Update RootLayout component to adjust container width based on route: integrate useRouterState for dynamic max-width handling, allowing admin pages to have a wider layout while keeping public pages compact. 2026-07-25 00:50:26 +03:00
Leonid Pershin 0cfc72166a Add TV bumpers functionality: introduce configuration options for bumpers in .env.example and appsettings.json, enhance ChannelEndpoints to manage jingles and bumper assets, and update Channel and ScheduleEntry models to support bumper logic. Implement validation for bumper settings and integrate bumper handling in scheduling logic. 2026-07-25 00:45:22 +03:00
Leonid Pershin 622bf1e440 Implement stream cookie deletion in logout and account deletion endpoints: add DeleteStreamCookie method to remove the stream cookie upon user logout and account deletion, enhancing security by preventing access to streams after token expiration. 2026-07-24 23:15:08 +03:00
Leonid Pershin 0891b4e6a8 Add loudness normalization settings: introduce NormalizeLoudness and LoudnessTargetLufs options in configuration files, update FfmpegMediaProcessor to apply loudness normalization during media processing, and document changes in media storage guide. 2026-07-24 23:08:10 +03:00
Leonid Pershin 65f2a1d4ec Enhance media upload functionality: add skipped file tracking in upload store, update UploadSnackbar to display skipped duplicates, and improve UI responsiveness for upload status indicators. 2026-07-24 22:35:09 +03:00
Leonid Pershin 8f8ce5122a Refactor media upload handling in MediaPanel: remove direct upload logic and integrate useUploadStore for file enqueueing. Update translations for upload status and add UploadSnackbar component to display upload notifications in the UI. 2026-07-24 22:25:01 +03:00
Leonid Pershin 2523808e3b Enhance schedule and show management: add season and episode information to ScheduleEntryDto, update ListShowsQueryHandler to include season counts, and modify frontend components to display this new data. Improve EPG handling by consolidating program entries into blocks for better clarity in the streaming interface. 2026-07-24 22:18:50 +03:00
Leonid Pershin 517f11c897 Enhance ShowDetail component: display season and episode information alongside asset names in a more structured format, improving user experience. Update AirPage to simplify program labels by removing episode index display, ensuring clarity in the streaming interface. 2026-07-24 21:55:28 +03:00
Leonid Pershin 1b078cdfd3 Enhance ChannelPlayer component: implement custom controls for play/pause, mute, and fullscreen functionality, and update UI to reflect live status. Integrate i18n support for live translations in both Russian and English. 2026-07-24 21:22:19 +03:00
Leonid Pershin 7b5c601821 Configure entity key generation in AppDbContext: set Guid keys for domain entities to never be generated by EF, preventing unintended updates during child entity additions. 2026-07-24 21:14:26 +03:00
Leonid Pershin 4202c51a5b Add maintenance management features: implement maintenance endpoints in the API, enhance media upload handling to skip duplicate files, and update frontend routes and translations for maintenance operations. 2026-07-24 19:52:56 +03:00
Leonid Pershin 6e7db4a6a9 Refactor media asset handling: update ListMediaAssetsQuery to accept an array of statuses, modify MediaEndpoints to handle multiple statuses, and enhance MediaProcessingQueue for improved signal handling. Update frontend components to support new status filtering and improve user experience with asset selection. 2026-07-24 19:22:22 +03:00
Leonid Pershin 8281832e1d Update routing for admin channels and shows: add index routes for channels and shows, refactor components to use Outlet for nested routing, and enhance type definitions for new routes in the routing structure. 2026-07-24 19:08:17 +03:00
Leonid Pershin b7cdc4ad96 Refactor media upload handling in MediaPanel: implement support for multiple file uploads with progress tracking, update UI to display current upload status, and enhance translations for uploaded file count. 2026-07-24 18:52:15 +03:00
Leonid Pershin 7309a25764 Remove downloads management features: delete DownloadsEndpoints and related API logic, remove associated frontend components and routes, and update documentation to reflect the removal of the downloads directory from the application structure. 2026-07-24 18:18:22 +03:00
Leonid Pershin 44d10f667c Update qbittorrent service configuration in Docker: switch to host network mode for improved DHT functionality, adjust WEBUI_PORT to 8090, and remove port mappings to prevent conflicts with app services. 2026-07-24 17:19:38 +03:00
Leonid Pershin e7840ba919 Add torrent client integration and downloads management: introduce qbittorrent service in Docker setup, implement downloads endpoint in API, and enhance media storage to handle imported files. Update frontend routes and translations for downloads management. 2026-07-24 16:42:14 +03:00
Leonid Pershin 5853009d71 Enhance AirPage and ChannelPlayer components: add error handling for unavailable streams, implement retry functionality, and update translations for offline states. Refactor ChannelPlayer to accept an onUnavailable callback for better error management. 2026-07-24 16:27:22 +03:00
Leonid Pershin a2685fb602 Add media and channel management routes: introduce new routes for admin channels, media, and shows in the routing structure. Update navigation in the admin layout to include links for these new sections. Enhance type definitions for media assets and shows in the API types. Integrate HLS.js for improved streaming support. 2026-07-24 16:18:54 +03:00
Leonid Pershin 1bbfd15907 Enhance configuration and routing for streaming features: update .env.example and appsettings.json to include new scheduler and streaming options, add streaming endpoint mapping in Program.cs, and integrate streaming services in DependencyInjection. Introduce segment path resolution in MediaPathResolver for asset management. 2026-07-24 16:07:37 +03:00
Leonid Pershin 4fa9dae37f Add broadcast scheduling features: implement Show and Channel entities, enhance AppDbContext and DependencyInjection for broadcasting, and update API routing. Include migration for new database schema and update documentation for broadcast-related functionalities. 2026-07-24 08:57:08 +03:00
Leonid Pershin e15ecbdb29 Implement media storage and processing features: update configuration in .env.example and appsettings files, enhance Docker setup for media storage, and add media-related services and database entities. Include ffmpeg for media handling and adjust Kestrel settings for large file uploads. 2026-07-24 08:33:11 +03:00
Leonid Pershin 1dd6991174 Enhance CSS for improved theme support: add color-scheme properties for light and dark modes, and explicitly define styles for native select elements to ensure proper color rendering in both themes. 2026-07-24 06:56:16 +03:00
Leonid Pershin b67cc960a0 Update infrastructure to use external PostgreSQL server instead of Docker container. Modify .env.example and documentation to reflect new connection settings and deployment instructions. 2026-07-24 06:33:11 +03:00
Leonid Pershin 8a3eebc48f Initial commit: base slice (auth, roles, users, admin) scaffold
Backend: .NET 10 Clean Architecture + LiteCqrs.Net + EF Core/PostgreSQL +
Identity/JWT. Frontend: React 19 + Vite + TanStack Query/Router + Tailwind v4
with a retro CRT theme. Docker/compose deployment mirroring PnvPanel's
conventions, scoped down to the current base feature set.
2026-07-24 05:40:34 +03:00
1032 changed files with 147259 additions and 138 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.3.0",
"commands": [
"csharpier"
],
"rollForward": false
}
}
}
+22
View File
@@ -0,0 +1,22 @@
# Базовые правила для редакторов — чтобы IDE не ставила свои отступы и концы строк до того, как
# файл дойдёт до форматтера. Итоговый стиль всё равно задают csharpier (.config/dotnet-tools.json)
# и Prettier (frontend/.prettierrc.json); значения здесь совпадают с их настройками намеренно —
# оба читают .editorconfig, и расхождение развернуло бы форматирование в другую сторону.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
max_line_length = 100
[*.cs]
indent_size = 4
[*.md]
# В markdown два пробела в конце строки — это перенос, обрезать их нельзя.
trim_trailing_whitespace = false
+118
View File
@@ -0,0 +1,118 @@
# TeleWave — пример переменных окружения.
# Скопируй в .env и заполни значения. Ключи вида Section__Key биндятся в IOptions<T> ASP.NET Core.
# ВСЕ значения-секреты ниже обязательно заменить перед запуском (особенно *_PASSWORD, *SigningKey).
# ── PostgreSQL (внешний сервер, не в compose) ─────────────────────────────
# База и пользователь создаются заранее на самом сервере БД (см. README — раздел "Развёртывание").
ConnectionStrings__Default=Host=10.10.1.134;Port=5432;Database=telewave;Username=telewave;Password=change-me-strong-db-password
# ── JWT ───────────────────────────────────────────────────────────────────
Jwt__Issuer=TeleWave
Jwt__Audience=TeleWave
Jwt__SigningKey=change-me-min-32-chars-random-secret
Jwt__AccessTokenMinutes=15
Jwt__RefreshTokenDays=30
# ── Сид администратора (создаётся при первом старте, если не существует) ───
# Логин в систему — по username. Email в системе не используется.
AdminSeed__Username=admin
AdminSeed__Password=change-me-strong-admin-password
# ── Rate limiting ────────────────────────────────────────────────────────
# Лимит запросов/мин на auth-эндпоинты (login/register/refresh). По умолчанию 20.
# RateLimiting__AuthPermitLimit=20
# ── Хранилище медиа ────────────────────────────────────────────────────────
# RootPath — путь ВНУТРИ контейнера; на хосте это bind-mount тома (см. docs/server-storage-setup.md
# и volumes в docker-compose.yml). Не меняй RootPath без синхронного изменения тома.
Storage__RootPath=/media
Storage__SegmentSeconds=2
# Сколько сегментов держать в скользящем окне live-плейлиста.
Storage__LiveWindowSegments=10
Storage__KeepOriginals=false
# Порог свободного места, ниже которого загрузка отклоняется (10 ГБ).
Storage__MinFreeSpaceBytes=10737418240
# Через сколько часов простоя убирать брошенное в uploads/ (след оборванной заливки или рестарта
# посреди неё). Идущую загрузку не задевает — отсчёт от времени последней записи. 0 — не убирать.
# Storage__StaleUploadHours=24
# TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа после
# блокировки/логаута; фронт перевыпускает cookie, пока идёт просмотр. По умолчанию 30.
# Storage__StreamTokenMinutes=30
# ── Планировщик эфира ──────────────────────────────────────────────────────
# HorizonDays — на сколько суток вперёд держать материализованное расписание. Неделя — часть замысла:
# «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
# RetentionDays — сколько суток прошедшей ленты хранить. Окно обязано покрывать самое долгое остывание
# и самый глубокий повтор среди правил канала: история показов берётся из самой ленты, отдельного
# журнала нет — укоротишь окно, и остывание с потолком повторов начнут «забывать» показы.
# TickMinutes — период фонового тика, достраивающего горизонт.
Scheduler__HorizonDays=7
Scheduler__RetentionDays=90
Scheduler__TickMinutes=30
# ── Обработка медиа (ffmpeg) ───────────────────────────────────────────────
Media__FfmpegPath=/usr/bin/ffmpeg
Media__FfprobePath=/usr/bin/ffprobe
# Максимальный размер загружаемого файла (20 ГБ).
Media__MaxUploadBytes=21474836480
# Потоки одного ffmpeg (0 — авто/все ядра).
Media__TranscodeThreads=3
# Сколько файлов транскодировать одновременно (1 — по умолчанию, как раньше).
# Тюнинг под ядра: обычно TranscodeThreads × MaxParallelTranscodes ≈ число ядер.
# 4 vCPU: Threads=3, Parallel=1 (или Threads=2, Parallel=2)
# 8 vCPU: Threads=4, Parallel=2 ← лучший throughput на пачках серий
# (или Threads=6, Parallel=1 — если важнее скорость одного файла)
# Память: один 1080p-транскод ~0.5 ГБ; Parallel=2 требует ~1 ГБ + база API.
Media__MaxParallelTranscodes=1
# Период опроса каталога inbox/ сканером, секунды.
Media__InboxScanSeconds=15
# Заводить фильм по файлу из inbox/, когда имя уверенно опознано в источнике метаданных.
# Неуверенное совпадение остаётся человеку: файл станет ассетом без шоу и попадёт в разбор.
Media__InboxAutoMovies=true
# Нормализация громкости при обработке (EBU R128 loudnorm) — все ролики одинаковой громкости.
Media__NormalizeLoudness=true
# Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить).
Media__LoudnessTargetLufs=-16
# Таймауты вызовов ffprobe/ffmpeg, секунды (0 — без таймаута): не дают зависшему процессу вечно
# держать слот параллелизма/тик планировщика. Поднять TranscodeTimeoutSeconds для очень длинных файлов.
# Media__ProbeTimeoutSeconds=120
# Media__TranscodeTimeoutSeconds=1800
# Предпочитаемые языки аудиодорожек ПО УМОЛЧАНИЮ (через запятую, в порядке приоритета) — если файл
# многоозвучный и есть дорожка с таким языком, при обработке берётся она. Обычно задаётся в админке
# («Настройки»); этот конфиг — дефолт, если в настройках пусто. Пусто — выбор ffmpeg по умолчанию.
# Media__PreferredAudioLanguages=rus,eng
# ── ТВ-заставки «Сейчас/Далее» (bumpers) ───────────────────────────────────
# Общие для всех каналов параметры рендера. Оформление и правила (цвета, подписи, длительность,
# интервал, только-между-разными) настраиваются на каждом канале в админке.
# Bumpers__Width=1280
# Bumpers__Height=720
# Bumpers__FontFileSans=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf
# Bumpers__FontFileSerif=/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf
# Меняй при правке ЛОГИКИ рендера, чтобы пересобрать уже отрендеренные заставки.
# Bumpers__TemplateVersion=1
# ── Метаданные шоу/серий (TMDb / OMDb) ─────────────────────────────────────
# Ключи бесплатные: 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__Tmdb__ApiKey=
Metadata__Omdb__ApiKey=
# Страны возрастной сертификации TMDb в порядке приоритета: берётся первая, у которой рейтинг есть.
# US впереди намеренно — его шкала (G/PG/PG-13/R/NC-17) совпадает с нашей и переводится точно;
# RU (0+…18+) приводится приблизительно и нужен ради покрытия там, где US-сертификации нет.
# Metadata__Tmdb__CertificationCountries__0=US
# Metadata__Tmdb__CertificationCountries__1=RU
# ── ASP.NET Core ──────────────────────────────────────────────────────────
ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_HTTP_PORTS=8080
# ── Доверенные прокси (X-Forwarded-For/Proto) ──────────────────────────────
# TLS терминируется вне compose внешним прокси/шлюзом. По умолчанию доверяется только loopback.
# Если прокси стоит не на loopback, перечисли его через запятую.
# ForwardedHeaders__KnownProxies=203.0.113.10
# ForwardedHeaders__KnownNetworks=172.18.0.0/16
+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
+170
View File
@@ -0,0 +1,170 @@
name: ci
# Один workflow вместо трёх: порядок build → tests → sonar задаётся через `needs`, а между
# отдельными файлами workflow'ов в Gitea связать этапы нечем. Побочный эффект — этапы идут
# последовательно, поэтому прогон дольше, зато тесты не стартуют на заведомо битой сборке,
# а анализ не уходит в SonarCloud с красными тестами.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
build-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
working-directory: backend
run: dotnet restore TeleWave.slnx
# Форматирование проверяем до сборки: падает за секунду и сразу показывает, что чинить.
# Версия csharpier прибита в .config/dotnet-tools.json — глобальная копия разработчика на CI
# не влияет, иначе её минорка переформатировала бы репозиторий в свой стиль.
- name: Format check
working-directory: backend
run: |
dotnet tool restore
dotnet csharpier check .
# Строгая сборка: TreatWarningsAsErrors=true из Directory.Build.props не отключаем.
- name: Build (Release)
working-directory: backend
run: dotnet build TeleWave.slnx -c Release --no-restore
build-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable
- name: Install
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Format check
working-directory: frontend
run: pnpm format:check
- name: Lint
working-directory: frontend
run: pnpm lint
- name: Typecheck
working-directory: frontend
run: pnpm typecheck
- name: Build
working-directory: frontend
run: pnpm build
tests:
runs-on: ubuntu-latest
needs: [build-backend, build-frontend]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
working-directory: backend
run: dotnet restore TeleWave.slnx
# Покрытие здесь не собираем: его считает джоба sonar своим прогоном (в формате opencover),
# а бейдж покрытия в README берётся из SonarCloud.
- name: Test
working-directory: backend
run: >
dotnet test TeleWave.slnx
-c Release
--no-restore
--logger "console;verbosity=normal"
# Анализ SonarCloud вместе с покрытием: один прогон собирает решение под сканером, гоняет тесты
# и отправляет результат. Только push в main — декорация pull request'ов у SonarCloud завязана
# на GitHub/GitLab, из Gitea она не работает, и анализ PR только засорял бы ветки в проекте.
sonar:
runs-on: ubuntu-latest
needs: tests
if: github.event_name != 'pull_request'
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST: https://sonarcloud.io
SONAR_PROJECT_KEY: mrleo1nid_telewave
SONAR_ORGANIZATION: mrleo1nid
steps:
- uses: actions/checkout@v4
with:
# Полная история — иначе Sonar не сопоставит изменения с авторами и «новым кодом».
fetch-depth: 0
# Сканер — java-приложение, на runner-образе Java может не оказаться.
- uses: actions/setup-java@v4
if: env.SONAR_TOKEN != ''
with:
distribution: temurin
java-version: 21
- uses: actions/setup-dotnet@v4
if: env.SONAR_TOKEN != ''
with:
dotnet-version: 10.0.x
- name: Install scanner
if: env.SONAR_TOKEN != ''
run: dotnet tool install --global dotnet-sonarscanner
# Сканер запускается из корня репозитория, а не из backend/: так в анализ попадает и фронт.
- name: Begin analysis
if: env.SONAR_TOKEN != ''
run: |
export PATH="$PATH:$HOME/.dotnet/tools"
dotnet sonarscanner begin \
/k:"$SONAR_PROJECT_KEY" \
/o:"$SONAR_ORGANIZATION" \
/d:sonar.host.url="$SONAR_HOST" \
/d:sonar.token="$SONAR_TOKEN" \
/d:sonar.cs.opencover.reportsPaths="**/coverage/**/coverage.opencover.xml" \
/d:sonar.exclusions="frontend/node_modules/**,frontend/dist/**,frontend/src/routeTree.gen.ts,backend/src/TeleWave.Infrastructure/Migrations/**" \
/d:sonar.coverage.exclusions="backend/src/TeleWave.Infrastructure/Migrations/**,frontend/**" \
/d:sonar.cpd.exclusions="frontend/src/shared/lib/locales/**"
# Локали исключены из поиска дублей намеренно: ru.ts и en.ts обязаны совпадать ключ в ключ —
# это не копипаста, а единственный способ не потерять перевод, и «устранение» такого дубля
# означало бы отказ от второго языка.
# Сканер подмешивает в сборку свои анализаторы, а в проекте TreatWarningsAsErrors=true —
# любое замечание Sonar роняло бы сборку вместо того, чтобы приехать в отчёт. Строгая
# сборка живёт в джобе build-backend, здесь она нужна только как носитель анализа.
- name: Build
if: env.SONAR_TOKEN != ''
run: dotnet build backend/TeleWave.slnx -c Release /p:TreatWarningsAsErrors=false
# Формат opencover, а не cobertura по умолчанию: C#-анализатор Sonar читает именно его.
# Интеграционные тесты без Docker пропускаются сами (см. PostgresFixture).
#
# Проекты гоняются по одному, а не одним прогоном по решению: при общем прогоне сборщик
# покрытия теряет попадания — отчёт приезжает без модуля TeleWave.Application, и в Sonar
# покрытые тестами файлы выглядят нулевыми. Отчёты складываются в один каталог, сканер
# подбирает их маской.
- name: Test + coverage
if: env.SONAR_TOKEN != ''
run: |
for project in Domain Application Integration; do
dotnet test "backend/tests/TeleWave.$project.Tests" \
-c Release \
--no-build \
--collect:"XPlat Code Coverage;Format=opencover" \
--results-directory backend/coverage \
--logger "console;verbosity=normal"
done
- name: End analysis
if: env.SONAR_TOKEN != ''
run: |
export PATH="$PATH:$HOME/.dotnet/tools"
dotnet sonarscanner end /d:sonar.token="$SONAR_TOKEN"
- name: Skipped
if: env.SONAR_TOKEN == ''
run: echo "SONAR_TOKEN не задан — анализ пропущен."
+91
View File
@@ -0,0 +1,91 @@
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# ---> VisualStudio
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
.vs/
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.VisualState.xml
TestResult.xml
nunit-*.xml
BenchmarkDotNet.Artifacts/
project.lock.json
project.fragment.lock.json
artifacts/
*.pdb
*.log
*.tlog
# NuGet Packages
*.nupkg
*.snupkg
**/[Pp]ackages/*
!**/[Pp]ackages/build/
# Others
*.pfx
*.publishsettings
# Node
node_modules/
dist/
dist-ssr/
# Local environment files (secrets) — keep .env.example, ignore real .env
.env
.env.local
.env.*.local
# Локальное медиахранилище для dev-запуска (Storage__RootPath в appsettings.Development.json)
.dev-media/
# Отчёты покрытия (артефакт dotnet test / CI)
coverage/
TestResults/
# ---> TeleWave
# Отладочные дампы каналов: архивы кладёт сюда бэкенд (секция Debug:ExportDirectory).
debug-exports/*
!debug-exports/.gitkeep
+128
View File
@@ -0,0 +1,128 @@
# CLAUDE.md
Инструкции для Claude Code при работе в этом репозитории.
## Что это
**TeleWave** — сервис онлайн-каналов: зритель смотрит сетку каналов, видео отдаётся из хранилища
на сервере. Админ ведёт библиотеку, каналы и эфир.
Работает: аутентификация, роли, пользователи; библиотека шоу/серий, коллекции, жанры, метаданные
(TMDb/OMDb), обработка медиа (ffmpeg → HLS), изображения; группы, шаблон сетки, стыки и заставки,
автосборка по профилям, обмен конфигурацией файлом и запросом к ИИ; генерация ленты, live-раздача,
публичный просмотр, статистика хранилища. Telegram-бот расписания (подписки на оповещения по каналам, прокси, опрос или вебхук). Конвенции — зеркало
[`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 Minimal API, Clean Architecture, CQRS через
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (лёгкая альтернатива MediatR с явным
разделением Command/Query — см. README). EF Core 10 + Npgsql, Identity + JWT, FluentValidation,
Serilog, маппинг DTO вручную, OpenAPI + Scalar UI, тесты на xUnit + NSubstitute.
- **Frontend**: React 19 + Vite + TS, TanStack Query/Router (файловые роуты, `routeTree.gen.ts`
генерируется сборкой), shadcn поверх Radix + Tailwind v4, Zustand (только auth+тема),
react-hook-form + zod, i18n (ru/en), pnpm + oxlint + prettier.
## Архитектура — жёсткие правила
Зависимости строго внутрь: **Api → Infrastructure → Application → Domain**.
- **Domain** — без внешних зависимостей: `Auth`, `Library` (шоу, серии, коллекции, жанры), `Media`,
`Images`, `Programming` (группы, сетка, слоты, стыки + **чистый планировщик** `Planning`),
`Broadcast` (каналы, заставки, лента, live), `Settings`. Модель rich: приватные сеттеры,
фабрики `Create`, поведенческие методы.
- **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,
AppRole, Guid>`, конфигурации в `Persistence/Configurations`), ffmpeg, хранилище, метаданные,
идемпотентный `DbInitializer` (роли + админ из env `AdminSeed__*`).
- **Api** — Minimal API (`Endpoints/*Endpoints.cs`), middleware, DI composition root (`Program.cs`).
Обязательно:
- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают. Диспетчер —
`ISender`/`ICommandHandler`/`IQueryHandler`, регистрация в `Application/DependencyInjection.cs`.
- Управляемые ошибки — через `Result<T>` (`Common/Models/Result.cs`), не исключениями. Валидация —
FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP; никаких `.Result`/`.Wait()`.
Nullable включён, `TreatWarningsAsErrors=true` — предупреждения анализаторов не игнорировать.
- Планировщик (`Domain/Programming/Planning`) — **чистая функция**: вход `PlanningInput`, никаких
запросов и часов внутри. Данные под него собирает `Application/Programming/Planning`.
## Инварианты, которые легко нарушить
- **Роли** динамические, у пользователя ровно одна; системные `admin`/`user` (`IsSystem=true`) не
переименовать и не удалить, последнего админа не разжаловать, себя не заблокировать и не удалить.
- **Регистрация открытая** (создаётся `user`, токены сразу); понадобится модерация — заводи отдельный
флаг, не переиспользуй `IsBlocked`. Вход по `UserName`, email и SMTP не используются; JWT access +
refresh в httpOnly `tw_refresh_token` с обнаружением повторного использования отозванного.
- **Правка сетки эфир не двигает**: команда поднимает ревизию шаблона, ленту пересобирает применение.
- **Вещательные сутки** начинаются с `Channel.DayStartTime` (06:00), сетка задаётся во времени
канала (`UtcOffsetMinutes`), в базе всё в UTC.
## Единый контейнер
- Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`), один
origin, база API — относительный `/api`. Multi-stage Dockerfile: node → dotnet sdk → aspnet.
Отдельный nginx для статики не вводить — ломает требование единого контейнера.
- Работает под непривилегированным `USER $APP_UID` (uid/gid 1654) **статически**, без
gosu/entrypoint: права на bind-mount выставляет оператор (`docs/server-storage-setup.md` § 5).
Data Protection не заводим — шифруемых секретов на диске нет. **TLS внешний**: `app` отдаёт HTTP
и доверяет `X-Forwarded-*`.
- docker-compose: только `app`. PostgreSQL внешний (`ConnectionStrings__Default` из `.env`), база
и пользователь создаются заранее вручную; миграции применяются на старте (`ApplyMigrationsAsync`),
прав `CREATEDB` не нужно.
## Соглашения по коду
- `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (где есть что проверить),
папки по фичам (`Auth/Login/`, `Programming/Templates/Generate/`, …). Один публичный тип на файл =
имя файла, кроме `Body` — тела Api-эндпоинтов, не совпадающие с командой 1:1 (`sealed record`
внизу файла эндпоинта). DTO — суффикс `Dto`.
- Ошибки — каталоги `XErrors` (`Error.Validation/NotFound/Conflict/…`), наружу единым
`application/problem+json` (`Api/Common/ResultExtensions.cs`). Секреты не логировать.
- **Изображения — только через реестр `Image`** (`Domain/Images`, галерея `features/admin/images`):
картинка хранится как `ImageId` и выбирается контролом `ImageGallery`/`GalleryBrowser`, своих
файловых полей не заводить. Файлы — `images/{id}{ext}`, отдаются по `/api/images/{id}`.
- Фронт: фича = папка в `src/features/admin/<фича>`; тексты только через i18n, ключи в `ru.ts` и `en.ts`.
- Комментарии объясняют **почему**, а не что; по-русски, как в существующем коде.
## Команды
Backend (из `backend/`):
```bash
dotnet build
dotnet csharpier format . # только через dotnet (версия из .config/); в CI — csharpier check
dotnet test tests/TeleWave.Domain.Tests # по одному проекту: MSBuild не берёт несколько
dotnet test tests/TeleWave.Application.Tests
dotnet test tests/TeleWave.Integration.Tests # Testcontainers; без Docker пропускаются
dotnet run --project src/TeleWave.Api
dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
```
Frontend (из `frontend/`):
```bash
pnpm install && pnpm dev
pnpm lint && pnpm typecheck && pnpm build
pnpm format && pnpm format:check # CI падает на неотформатированном — прогоняй после правок
```
Инфраструктура: `docker compose up -d --build` (только api; postgres внешний). Окружение — Windows,
оболочка **PowerShell**; для POSIX-скриптов есть Bash-инструмент.
## Рабочие принципы
- Базовый срез реализован — правь по месту. Новые крупные направления с непринятыми решениями
(многоэкземплярность, транскод-профили, подсистемы) — после сверки; неоднозначность — вопрос.
- Меняешь поведение подсистемы — обнови её спеку в `docs/` тем же заходом.
- Соблюдай границы слоёв — главный инвариант проекта.
- Правь файлы через Edit/Write: пользователь видит дифф, а harness не даст перезаписать файл вслепую.
Скриптом на python — только при крайней необходимости (массовая механическая замена), и сказать явно.
- Не коммить и не пуши без явной просьбы; отвечай пользователю на русском.
+48
View File
@@ -0,0 +1,48 @@
# syntax=docker/dockerfile:1
# ── Stage 1: сборка фронтенда (Vite → dist) ───────────────────────────────
FROM node:22-alpine AS frontend
WORKDIR /app/frontend
RUN corepack enable
# pnpm-workspace.yaml здесь не про воркспейсы: в pnpm 11 там живут overrides, и без него
# --frozen-lockfile падает на несовпадении конфигурации с локом.
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
# --ignore-scripts: ни один пакет фронта не требует postinstall, поэтому lifecycle-скрипты зависимостей
# в сборке не выполняются — компрометация любого пакета в дереве не даёт исполнения кода на этапе install.
RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile --ignore-scripts
COPY frontend/ ./
RUN pnpm build
# ── Stage 2: publish бэкенда, статика фронта в wwwroot ────────────────────
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend
WORKDIR /src
COPY backend/Directory.Build.props backend/Directory.Packages.props ./backend/
COPY backend/src/TeleWave.Domain/TeleWave.Domain.csproj ./backend/src/TeleWave.Domain/
COPY backend/src/TeleWave.Application/TeleWave.Application.csproj ./backend/src/TeleWave.Application/
COPY backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj ./backend/src/TeleWave.Infrastructure/
COPY backend/src/TeleWave.Api/TeleWave.Api.csproj ./backend/src/TeleWave.Api/
RUN dotnet restore backend/src/TeleWave.Api/TeleWave.Api.csproj
COPY backend/ ./backend/
# Статика собранного фронта → wwwroot (перекрывает заглушку)
COPY --from=frontend /app/frontend/dist/ ./backend/src/TeleWave.Api/wwwroot/
RUN dotnet publish backend/src/TeleWave.Api/TeleWave.Api.csproj -c Release -o /app/publish --no-restore
# ── Stage 3: runtime ──────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# curl — для HEALTHCHECK; ffmpeg — нормализация и нарезка медиа при загрузке (см. Media__FfmpegPath);
# fonts-dejavu-core — кириллический TTF для текста на ТВ-заставках (drawtext, см. Bumpers__FontFile).
RUN apt-get update && apt-get install -y --no-install-recommends curl ffmpeg fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
COPY --from=backend /app/publish ./
# Работаем под непривилегированным пользователем образа aspnet (app, uid/gid 1654 — $APP_UID задан
# в самом образе). Порт 8080 непривилегированный, установка пакетов уже позади, приложению нужна
# только запись в /media. ВАЖНО: bind-mount пробрасывает права хоста как есть — каталог хранилища
# должен принадлежать uid 1654, иначе контейнер не сможет писать (см. docs/server-storage-setup.md § 5).
USER $APP_UID
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "TeleWave.Api.dll"]
+86
View File
@@ -0,0 +1,86 @@
# TeleWave
[![ci](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/ci.yml/badge.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)
[![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** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
хранилища на сервере, админ управляет каналами и пользователями.
> Текущее состояние — **рабочий вертикальный срез**: вход/регистрация, роли, пользователи, админка;
> библиотека шоу/серий, загрузка и обработка медиа (ffmpeg → HLS), метаданные (TMDb/OMDb), каталог
> каналов, планировщик эфира (реклама, ТВ-заставки, weekly-override'ы) и live-раздача HLS с публичным
> просмотром сетки. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL —
> **внешний**, не поднимается через compose.
## Стек
| Слой | Технологии |
| -------- | ---------------------------------------------------------------------------------------- |
| Backend | C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS ([LiteCqrs.Net](https://github.com/mrleo1nid/LiteCqrs.Net)), EF Core |
| БД | PostgreSQL (Npgsql) |
| Auth | ASP.NET Core Identity + JWT (access + refresh, ротация) |
| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind v4 (ретро-CRT тема) |
| Упаковка | Единый Docker-образ (API + статика SPA); PostgreSQL — внешний сервер |
## Развёртывание
Postgres не входит в compose — база и пользователь создаются заранее на внешнем сервере БД:
```sql
-- на сервере БД (psql -h <хост БД> -U postgres)
CREATE USER telewave WITH PASSWORD 'change-me-strong-db-password';
CREATE DATABASE telewave OWNER telewave;
```
Затем на хосте с приложением:
```bash
cp .env.example .env
# заполнить ConnectionStrings__Default (хост/база/юзер/пароль БД), Jwt__SigningKey, AdminSeed__*
docker compose up -d --build
# → http://localhost:8085 (админ — логин/пароль из .env, AdminSeed__Username/Password)
```
**Права на медиахранилище.** Контейнер работает под непривилегированным пользователем (uid/gid
`1654`), а bind-mount пробрасывает права хоста как есть — каталог хранилища должен принадлежать
этому uid, иначе приложение стартует, но любая запись в `/media` упадёт с `Permission denied`.
Полная процедура (включая группу, через которую вы сами кладёте файлы в `inbox/` и `manual/`) —
[`docs/server-storage-setup.md`](docs/server-storage-setup.md), шаг 5:
```bash
sudo groupadd -g 1654 telewave
sudo usermod -aG telewave "$USER" # затем перелогиниться
sudo chown -R 1654:1654 /srv/telewave/media
sudo chmod -R 750 /srv/telewave/media
sudo chmod 2775 /srv/telewave/media/inbox /srv/telewave/media/manual
```
> Обновляетесь с версии, где контейнер работал под root? Это и есть вся миграция: остановите
> контейнер (`docker compose down`), выполните команды выше, поднимайте новый образ.
## Локальная разработка
```bash
# backend, из backend/
dotnet build && dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests
dotnet run --project src/TeleWave.Api
# frontend, из frontend/
pnpm install
pnpm dev # проксирует /api на localhost:8080
```
## Документация
Пример переменных окружения (сид админа, БД, JWT) — [`.env.example`](.env.example).
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md) (стек, архитектурные
правила, доменные инварианты, соглашения по коду).
## Лицензия
Не определена.
+70
View File
@@ -0,0 +1,70 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0F9113EE-888A-26D2-68B0-4A7D0A2A8745}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Api", "backend\src\TeleWave.Api\TeleWave.Api.csproj", "{637772B7-C13F-21C4-0C66-BA606166F604}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Application", "backend\src\TeleWave.Application\TeleWave.Application.csproj", "{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Domain", "backend\src\TeleWave.Domain\TeleWave.Domain.csproj", "{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Infrastructure", "backend\src\TeleWave.Infrastructure\TeleWave.Infrastructure.csproj", "{9D8E3873-E632-F209-1AA0-619B3FC683AC}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F57642F3-C37C-D174-720E-6A6AAD5BEE22}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Application.Tests", "backend\tests\TeleWave.Application.Tests\TeleWave.Application.Tests.csproj", "{E27F38DB-A6A4-A33F-0C57-9885DD209034}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Domain.Tests", "backend\tests\TeleWave.Domain.Tests\TeleWave.Domain.Tests.csproj", "{EA5A4356-A5CE-7650-74A5-53D50CB50872}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{637772B7-C13F-21C4-0C66-BA606166F604}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{637772B7-C13F-21C4-0C66-BA606166F604}.Debug|Any CPU.Build.0 = Debug|Any CPU
{637772B7-C13F-21C4-0C66-BA606166F604}.Release|Any CPU.ActiveCfg = Release|Any CPU
{637772B7-C13F-21C4-0C66-BA606166F604}.Release|Any CPU.Build.0 = Release|Any CPU
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Release|Any CPU.Build.0 = Release|Any CPU
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Release|Any CPU.Build.0 = Release|Any CPU
{9D8E3873-E632-F209-1AA0-619B3FC683AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9D8E3873-E632-F209-1AA0-619B3FC683AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9D8E3873-E632-F209-1AA0-619B3FC683AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9D8E3873-E632-F209-1AA0-619B3FC683AC}.Release|Any CPU.Build.0 = Release|Any CPU
{E27F38DB-A6A4-A33F-0C57-9885DD209034}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E27F38DB-A6A4-A33F-0C57-9885DD209034}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E27F38DB-A6A4-A33F-0C57-9885DD209034}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E27F38DB-A6A4-A33F-0C57-9885DD209034}.Release|Any CPU.Build.0 = Release|Any CPU
{EA5A4356-A5CE-7650-74A5-53D50CB50872}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EA5A4356-A5CE-7650-74A5-53D50CB50872}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EA5A4356-A5CE-7650-74A5-53D50CB50872}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EA5A4356-A5CE-7650-74A5-53D50CB50872}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{0F9113EE-888A-26D2-68B0-4A7D0A2A8745} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
{637772B7-C13F-21C4-0C66-BA606166F604} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{9D8E3873-E632-F209-1AA0-619B3FC683AC} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{F57642F3-C37C-D174-720E-6A6AAD5BEE22} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
{E27F38DB-A6A4-A33F-0C57-9885DD209034} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
{EA5A4356-A5CE-7650-74A5-53D50CB50872} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {CD8641E5-D878-4C5E-BE1C-314F2280EC6E}
EndGlobalSection
EndGlobal
+26
View File
@@ -0,0 +1,26 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest</AnalysisLevel>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!--
CA1859 (сменить интерфейс коллекции на конкретный тип ради девиртуализации) — осознанно
выключено: помечаются им границы API (доменные модели планировщика, порты, хелперы тестов),
и «ускорение» там означает изменяемую коллекцию, протекающую через границу. Пути не горячие
(генерация расписания идёт раз в тик, а не в цикле по кадрам). Понадобится — включать точечно
по месту, а не глобально.
ASP0018 (параметр маршрута не используется хендлером) — тоже осознанно: часть шаблонов
объявляет сегменты ради формы URL, а не ради хендлера. Так, превью заставки адресуется
подблоком, но канал и блок остаются в пути, потому что по ним строит ссылки плейлист;
у /{slug}/watch токен подтверждает зрителя, а не подписку на канал.
-->
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873;CA1859;ASP0018</NoWarn>
</PropertyGroup>
</Project>
+49
View File
@@ -0,0 +1,49 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
<PackageVersion
Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore"
Version="10.0.10"
/>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<!-- Держим 2.x: Microsoft.AspNetCore.OpenApi 10.0.10 сам зависит от Microsoft.OpenApi >=2.0.0
(nuspec) и его Roslyn source generator (XmlCommentGenerator) скомпилирован под 2.x API —
3.x меняет IOpenApiMediaType.Example на read-only и ломает генерацию (CS0200). -->
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
<PackageVersion
Include="Microsoft.Extensions.DependencyInjection.Abstractions"
Version="10.0.10"
/>
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.21.0" />
<PackageVersion Include="LiteCqrs.Net" Version="1.0.1" />
<!-- Тестирование -->
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
</ItemGroup>
</Project>
+13
View File
@@ -0,0 +1,13 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/TeleWave.Api/TeleWave.Api.csproj" />
<Project Path="src/TeleWave.Application/TeleWave.Application.csproj" />
<Project Path="src/TeleWave.Domain/TeleWave.Domain.csproj" />
<Project Path="src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj" />
<Project Path="tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj" />
<Project Path="tests/TeleWave.Integration.Tests/TeleWave.Integration.Tests.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,4 @@
namespace TeleWave.Api.Common;
/// <summary>Единый ответ на создание сущности — её идентификатор.</summary>
public sealed record CreatedIdResponse(Guid Id);
@@ -0,0 +1,6 @@
namespace TeleWave.Api.Common;
public static class RateLimiting
{
public const string AuthPolicy = "auth";
}
@@ -0,0 +1,27 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Api.Common;
public static class ResultExtensions
{
public static IResult ToHttpResult(this Result result) =>
result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result) =>
result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
public static IResult ToProblem(this Error error)
{
var statusCode = error.Type switch
{
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.Unauthorized => StatusCodes.Status401Unauthorized,
ErrorType.Forbidden => StatusCodes.Status403Forbidden,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status422UnprocessableEntity,
};
return Results.Problem(title: error.Code, detail: error.Message, statusCode: statusCode);
}
}
@@ -0,0 +1,34 @@
using System.Text.RegularExpressions;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Common;
/// <summary>
/// Общая проверка файлов нарезки для всех эндпоинтов, отдающих HLS: эфир, превью заставок.
/// Имя сегмента сверяется с allowlist, а путь резолвится через <see cref="MediaPathResolver"/>,
/// который бросает <see cref="UnauthorizedAccessException"/> на попытку выйти за пределы каталога —
/// наружу это должно выглядеть как обычный 404, а не как ошибка сервера.
/// </summary>
internal static partial class SegmentFiles
{
[GeneratedRegex(@"^seg\d{1,6}\.ts$")]
private static partial Regex SegmentName();
public static bool IsSegmentName(string file) => SegmentName().IsMatch(file);
/// <summary>Путь к существующему файлу нарезки, либо null — если имя опасно или файла нет.</summary>
public static string? TryResolveExisting(MediaPathResolver paths, Guid assetId, string fileName)
{
string path;
try
{
path = paths.SegmentPath(assetId, fileName);
}
catch (UnauthorizedAccessException)
{
return null;
}
return File.Exists(path) ? path : null;
}
}
@@ -0,0 +1,18 @@
using Microsoft.Extensions.Options;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Common;
/// <summary>
/// Лимиты приёма файла, собранные в одно место. Они живут в двух разных секциях конфигурации
/// (<c>Media</c> и <c>Storage</c>), но проверяются всегда вместе и только на входе загрузки —
/// хендлеру незачем знать про обе секции и тащить два <see cref="IOptions{T}"/> в сигнатуре.
/// </summary>
public sealed class UploadLimits(IOptions<MediaOptions> media, IOptions<StorageOptions> storage)
{
/// <summary>Потолок размера загружаемого файла.</summary>
public long MaxUploadBytes { get; } = media.Value.MaxUploadBytes;
/// <summary>Сколько места должно остаться свободным после приёма файла.</summary>
public long MinFreeSpaceBytes { get; } = storage.Value.MinFreeSpaceBytes;
}
@@ -0,0 +1,137 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Admin.Users.BlockUser;
using TeleWave.Application.Admin.Users.CreateUser;
using TeleWave.Application.Admin.Users.DeleteUser;
using TeleWave.Application.Admin.Users.ListUsers;
using TeleWave.Application.Admin.Users.ResetPassword;
using TeleWave.Application.Admin.Users.UnblockUser;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class AdminUserEndpoints
{
public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/users")
.WithTags("Admin.Users")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListUsers).Produces<PagedList<UserSummaryDto>>();
admin.MapPost("", CreateUser).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPost("/{id:guid}/block", BlockUser).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/password", ResetPassword)
.Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListUsers(
[AsParameters] ListUsersFilter filter,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ListUsersQuery(
filter.Page is > 0 ? filter.Page.Value : 1,
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
filter.Search,
filter.RoleId,
filter.IsBlocked,
filter.Sort,
filter.Desc ?? false
),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> CreateUser(
CreateUserBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateUserCommand(body.UserName, body.Password, body.RoleId),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/users/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> BlockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UnblockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ResetPassword(
Guid id,
ResetPasswordBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ResetUserPasswordCommand(id, body.NewPassword),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
/// <summary>
/// Фильтр и страница списка пользователей. Все поля nullable намеренно: обязательный параметр в query
/// превращает запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело. Умолчания
/// подставляет сам хендлер, а не объявление: при <c>AsParameters</c> обязательность определяется
/// nullable-типом, а не наличием значения по умолчанию в конструкторе.
/// </summary>
public sealed record ListUsersFilter(
int? Page,
int? PageSize,
string? Search,
Guid? RoleId,
bool? IsBlocked,
string? Sort,
bool? Desc
);
public sealed record CreateUserBody(string UserName, string Password, Guid RoleId);
public sealed record ResetPasswordBody(string NewPassword);
@@ -0,0 +1,250 @@
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Auth;
using TeleWave.Application.Auth.ChangePassword;
using TeleWave.Application.Auth.ChangeUserName;
using TeleWave.Application.Auth.DeleteMyAccount;
using TeleWave.Application.Auth.Login;
using TeleWave.Application.Auth.Logout;
using TeleWave.Application.Auth.Me;
using TeleWave.Application.Auth.Refresh;
using TeleWave.Application.Auth.Register;
using TeleWave.Application.Settings.GetSiteSettings;
namespace TeleWave.Api.Endpoints;
public static class AuthEndpoints
{
private const string RefreshCookieName = "tw_refresh_token";
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth")
.WithTags("Auth")
.RequireRateLimiting(RateLimiting.AuthPolicy);
group.MapGet("/registration", RegistrationStatus).Produces<RegistrationStatusDto>();
group.MapPost("/register", Register).Produces<AuthResponseDto>();
group.MapPost("/login", Login).Produces<AuthResponseDto>();
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
group
.MapPost("/logout", Logout)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-password", ChangePassword)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-username", ChangeUserName)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>();
group
.MapDelete("/me", DeleteMe)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> RegistrationStatus(
ISender sender,
CancellationToken cancellationToken
)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(new RegistrationStatusDto(settings.RegistrationEnabled));
}
private static async Task<IResult> Register(
RegisterCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt,
UseSecureCookie(request, env)
);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Login(
LoginCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt,
UseSecureCookie(request, env)
);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Refresh(
HttpRequest request,
HttpResponse response,
ISender sender,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
if (
!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
|| string.IsNullOrEmpty(rawToken)
)
return Results.Unauthorized();
var secure = UseSecureCookie(request, env);
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
if (!result.IsSuccess)
{
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
return result.ToHttpResult();
}
SetRefreshCookie(
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt,
secure
);
return Results.Ok(ToLoginResponse(result.Value));
}
internal static AuthResponseDto ToLoginResponse(AuthResult auth) =>
new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User);
private static async Task<IResult> Logout(
HttpRequest request,
HttpResponse response,
ISender sender,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
if (
request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
&& !string.IsNullOrEmpty(rawToken)
)
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
var secure = UseSecureCookie(request, env);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
DeleteStreamCookie(response, secure);
return Results.NoContent();
}
private static async Task<IResult> ChangePassword(
ChangePasswordCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserName(
ChangeUserNameCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Me(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteMe(
HttpRequest request,
HttpResponse response,
ISender sender,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
var secure = UseSecureCookie(request, env);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
DeleteStreamCookie(response, secure);
return result.ToHttpResult();
}
private static void SetRefreshCookie(
HttpResponse response,
string rawToken,
DateTimeOffset expiresAt,
bool secure
)
{
var options = BuildCookieOptions(secure);
options.Expires = expiresAt;
response.Cookies.Append(RefreshCookieName, rawToken, options);
}
private static CookieOptions BuildCookieOptions(bool secure) =>
new()
{
HttpOnly = true,
Secure = secure,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
};
/// <summary>Ставить ли cookie с флагом Secure. Вне Development — всегда true: прод работает за
/// внешним TLS-прокси, а <see cref="HttpRequest.IsHttps"/> ненадёжен (при неполной настройке
/// ForwardedHeaders он false, и долгоживущий refresh-cookie ушёл бы без Secure). В Development
/// допускаем HTTP-разработку.</summary>
private static bool UseSecureCookie(HttpRequest request, IHostEnvironment env) =>
!env.IsDevelopment() || request.IsHttps;
/// <summary>Гасит stream-cookie (tw_stream, Path=/api) — иначе после выхода эфир можно смотреть
/// по прямой ссылке ещё до истечения токена.</summary>
private static void DeleteStreamCookie(HttpResponse response, bool secure) =>
response.Cookies.Delete(
"tw_stream",
new CookieOptions
{
HttpOnly = true,
Secure = secure,
SameSite = SameSiteMode.Strict,
Path = "/api",
}
);
}
public sealed record AuthResponseDto(
string AccessToken,
DateTimeOffset ExpiresAt,
CurrentUserDto User
);
/// <summary>Публичный ответ: включена ли открытая регистрация (для страниц входа/регистрации).</summary>
public sealed record RegistrationStatusDto(bool Enabled);
@@ -0,0 +1,333 @@
using System.Text;
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Блоки ТВ-заставок: оформление, звук, подблоки с текстом и рендер превью. Блоки общие для всех
/// каналов, поэтому и раздел свой, не канальный — канал только ссылается на них врезками стыков.
/// </summary>
public static class BumperEndpoints
{
public static IEndpointRouteBuilder MapBumperEndpoints(this IEndpointRouteBuilder app)
{
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,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateBumperTemplateCommand(body.Name),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/bumpers/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> Update(
Guid templateId,
UpdateBumperTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateBumperTemplateCommand(
templateId,
new BumperStyle(
body.Name,
body.Font,
body.BackgroundColor,
body.BackgroundColor2,
body.AccentColor,
body.TextColor
)
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Delete(
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new DeleteBumperTemplateCommand(templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UploadAudio(
Guid templateId,
string fileName,
HttpRequest request,
IBumperTemplateStorage storage,
IAudioProbe probe,
ISender sender,
CancellationToken cancellationToken
)
{
if (ResolveExtension(fileName, request) is not { } ext)
return BumperErrors.InvalidFile.ToProblem();
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
var path = storage.AudioPath(templateId, ext);
var duration = path is null
? null
: await probe.TryGetDurationAsync(path, cancellationToken);
var result = await sender.Send(
new SetBumperTemplateAudioCommand(templateId, ext, duration?.TotalSeconds ?? 0),
cancellationToken
);
if (!result.IsSuccess)
await storage.DeleteAudioAsync(templateId, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearAudio(
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ClearBumperTemplateAudioCommand(templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetBackground(
Guid templateId,
SetBumperBackgroundBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetBumperTemplateBackgroundCommand(templateId, body.ImageId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ClearBackground(
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ClearBumperTemplateBackgroundCommand(templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddVariant(
Guid templateId,
BumperNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddBumperVariantCommand(templateId, body.Name),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/bumpers/{templateId}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateVariant(
Guid templateId,
Guid variantId,
BumperVariantInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateBumperVariantCommand(templateId, variantId, input),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveVariant(
Guid templateId,
Guid variantId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveBumperVariantCommand(templateId, variantId),
cancellationToken
);
return result.ToHttpResult();
}
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
private static async Task<IResult> RenderPreview(
Guid templateId,
Guid? channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RenderBumperPreviewCommand(templateId, channelId),
cancellationToken
);
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
}
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid templateId, Guid variantId, MediaPathResolver paths)
{
var previewId = BumperPreview.AssetId(variantId);
if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath)
return Results.NotFound();
var baseUrl = $"/api/admin/bumpers/{templateId}/preview/{variantId}/";
var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
continue;
// Комментарии/директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
.Append('\n');
}
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
}
/// <summary>
/// Сегмент превью. Блок в маршруте есть, но хендлеру не нужен: каталог превью адресуется
/// подблоком (см. <see cref="BumperPreview.AssetId"/>) — незаявленные параметры не связываются.
/// </summary>
private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
{
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
var previewId = BumperPreview.AssetId(variantId);
if (SegmentFiles.TryResolveExisting(paths, previewId, file) is not { } path)
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
private static string? ResolveExtension(string fileName, HttpRequest request)
{
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
return null;
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return BumperFiles.AudioExtensions.Contains(ext) ? ext : null;
}
}
public sealed record BumperNameBody(string Name);
public sealed record UpdateBumperTemplateBody(
string Name,
BumperFont Font,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor
);
public sealed record SetBumperBackgroundBody(Guid ImageId);
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
internal static class BumperFiles
{
public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ
public static readonly IReadOnlySet<string> AudioExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
".mp3",
".m4a",
".aac",
".ogg",
".opus",
".wav",
};
}
@@ -0,0 +1,210 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelTime;
using TeleWave.Application.Broadcast.UpdateViewerSettings;
using TeleWave.Application.Programming.Planning.DebugExport;
using TeleWave.Application.Programming.Planning.Trace;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки
/// и стыки общие для всех каналов и живут своими разделами (<c>BumperEndpoints</c>,
/// <c>JunctionEndpoints</c>); что и когда идёт в эфире, задаёт шаблон сетки (<c>TemplateEndpoints</c>).
/// </summary>
public static class ChannelEndpoints
{
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/channels")
.WithTags("Admin.Channels")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
admin
.MapPut("/{id:guid}/settings", UpdateSettings)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
admin
.MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>();
admin
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
.Produces(StatusCodes.Status204NoContent);
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
// Отладочный дамп: вход планировщика и результат одним архивом.
admin.MapPost("/{id:guid}/debug-export", ExportDebug).Produces(StatusCodes.Status200OK);
return app;
}
/// <summary>
/// Отдаёт архив со снимком канала. POST, а не GET: команда оставляет копию дампа на сервере,
/// и кэшировать такой ответ никому не следует.
/// </summary>
private static async Task<IResult> ExportDebug(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ExportChannelDebugCommand(id), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
return Results.File(result.Value.Content, "application/zip", result.Value.FileName);
}
private static async Task<IResult> UpdateViewerSettings(
Guid id,
UpdateViewerSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateViewerSettingsCommand(
id,
body.LogoImageId,
body.LogoCorner,
body.LogoOpacity,
body.ShowClock,
body.AnalogFilterStrength
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetEntryTrace(
Guid entryId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateChannel(
CreateChannelCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetChannel(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTime(
Guid id,
UpdateChannelTimeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
Guid id,
UpdateChannelSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelSettingsCommand(
id,
body.Name,
body.IsEnabled,
body.FillerAssetId,
body.IconImageId
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetSchedule(
Guid id,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddDays(1);
var result = await sender.Send(
new GetChannelScheduleQuery(id, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
}
/// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
public sealed record UpdateChannelTimeBody(
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime
);
public sealed record UpdateChannelSettingsBody(
string Name,
bool IsEnabled,
Guid? FillerAssetId,
Guid? IconImageId
);
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
public sealed record UpdateViewerSettingsBody(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
@@ -0,0 +1,257 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library.Collections;
using TeleWave.Application.Library.Collections.AddCollectionShow;
using TeleWave.Application.Library.Collections.AddShowsToCollection;
using TeleWave.Application.Library.Collections.CreateCollection;
using TeleWave.Application.Library.Collections.DeleteCollection;
using TeleWave.Application.Library.Collections.FillCollectionPosters;
using TeleWave.Application.Library.Collections.GetCollection;
using TeleWave.Application.Library.Collections.ListCollections;
using TeleWave.Application.Library.Collections.RemoveCollectionShow;
using TeleWave.Application.Library.Collections.ReorderCollection;
using TeleWave.Application.Library.Collections.SetCollectionPoster;
using TeleWave.Application.Library.Collections.Suggest;
using TeleWave.Application.Library.Collections.UpdateCollection;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class CollectionEndpoints
{
public static IEndpointRouteBuilder MapCollectionEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/collections")
.WithTags("Admin.Collections")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListCollections).Produces<IReadOnlyList<CollectionSummaryDto>>();
admin.MapGet("/{id:guid}", GetCollection).Produces<CollectionDto>();
admin
.MapPost("", CreateCollection)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/{id:guid}", UpdateCollection).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteCollection).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/shows", AddShow).Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/shows/{showId:guid}", RemoveShow)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/poster-image", SetPoster).Produces(StatusCodes.Status204NoContent);
// Сборка франшиз: массовое добавление отмеченных шоу и предложения по библиотеке.
admin.MapPost("/bulk/shows", AddShows).Produces<AddShowsToCollectionResultDto>();
// Постеры по частям: без списка — всем коллекциям, у которых постера ещё нет.
admin.MapPost("/bulk/posters", FillPosters).Produces<int>();
admin
.MapGet("/suggestions", Suggestions)
.Produces<IReadOnlyList<CollectionSuggestionDto>>();
admin
.MapPost("/suggestions", CreateFromSuggestion)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
// Дополнение уже собранной франшизы: коллекция определяется тем же ключом предложения.
admin.MapPost("/suggestions/add", AddFromSuggestion).Produces<int>();
return app;
}
private static async Task<IResult> FillPosters(
FillCollectionPostersBody? body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new FillCollectionPostersCommand(body?.CollectionIds),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddShows(
AddShowsToCollectionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddShowsToCollectionCommand(
body.ShowIds,
body.CollectionId,
body.NewCollectionName
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Suggestions(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SuggestCollectionsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> CreateFromSuggestion(
CreateCollectionFromSuggestionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateCollectionFromSuggestionCommand(body.Key),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/collections/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> AddFromSuggestion(
CreateCollectionFromSuggestionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new AddSuggestedShowsCommand(body.Key), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListCollections(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListCollectionsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetCollection(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetCollectionQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateCollection(
CreateCollectionCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/collections/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateCollection(
Guid id,
UpdateCollectionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateCollectionCommand(id, body.Name, body.Description),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteCollection(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteCollectionCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddShow(
Guid id,
CollectionShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddCollectionShowCommand(id, body.ShowId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveShow(
Guid id,
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveCollectionShowCommand(id, showId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Reorder(
Guid id,
ReorderCollectionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ReorderCollectionCommand(id, body.ShowIdsInOrder),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetPoster(
Guid id,
CollectionPosterBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetCollectionPosterCommand(id, body.ImageId),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateCollectionBody(string Name, string? Description);
public sealed record CollectionShowBody(Guid ShowId);
public sealed record ReorderCollectionBody(IReadOnlyList<Guid> ShowIdsInOrder);
public sealed record CollectionPosterBody(Guid? ImageId);
/// <summary>Куда складывать: существующая коллекция либо новая по имени.</summary>
public sealed record AddShowsToCollectionBody(
IReadOnlyList<Guid> ShowIds,
Guid? CollectionId = null,
string? NewCollectionName = null
);
public sealed record CreateCollectionFromSuggestionBody(string Key);
public sealed record FillCollectionPostersBody(IReadOnlyList<Guid>? CollectionIds);
@@ -0,0 +1,162 @@
using System.Reflection;
using System.Runtime.InteropServices;
using LiteCqrs;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Options;
using TeleWave.Application.Diagnostics;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Админская диагностика. Главное здесь — картина прокси-форвардинга: заголовки, которым доверяет
/// приложение, и итоговая схема/хост, из которых строятся абсолютные ссылки (M3U/EPG). Именно тут
/// видно, почему ссылки уходят по http вместо https: X-Forwarded-Proto пришёл, но сосед не доверен.
/// Плюс живое состояние инфраструктуры (БД, том хранилища, ffmpeg) — через системный запрос.
/// </summary>
public static class DiagnosticsEndpoints
{
public static IEndpointRouteBuilder MapDiagnosticsEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/admin/diagnostics", Get)
.WithTags("Admin.Diagnostics")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))
.Produces<DiagnosticsResponse>();
return app;
}
private static async Task<IResult> Get(
HttpContext http,
IOptions<ForwardedHeadersOptions> forwardedOptions,
IHostEnvironment env,
ISender sender,
CancellationToken cancellationToken
)
{
var system = await sender.Send(new GetSystemDiagnosticsQuery(), cancellationToken);
var host = BuildHost(env);
var forwarding = BuildForwarding(http, forwardedOptions.Value);
return Results.Ok(new DiagnosticsResponse(host, forwarding, system));
}
private static HostDiagnosticsDto BuildHost(IHostEnvironment env)
{
var assembly = Assembly.GetEntryAssembly();
var version =
assembly
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion
?? assembly?.GetName().Version?.ToString();
return new HostDiagnosticsDto(
version,
env.EnvironmentName,
RuntimeInformation.FrameworkDescription,
RuntimeInformation.OSDescription,
DateTimeOffset.UtcNow
);
}
private static ForwardingDiagnosticsDto BuildForwarding(
HttpContext http,
ForwardedHeadersOptions options
)
{
var request = http.Request;
var scheme = request.Scheme;
var host = request.Host.Value ?? string.Empty;
var isHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase);
var remoteIp = http.Connection.RemoteIpAddress?.ToString();
var forwardedProto = Header(request, "X-Forwarded-Proto");
// X-Original-* заголовки ставит сам ForwardedHeaders-middleware, когда доверяет соседу и
// применяет форвардинг — по их наличию отличаем «применено» от «отброшено как недоверенное».
var originalProto = Header(request, "X-Original-Proto");
var originalRemoteIp = Header(request, "X-Original-For");
var applied = originalProto is not null || originalRemoteIp is not null;
// Что прокси реально прислал (до возможного применения), и вывод с готовой подсказкой.
var sentProto = originalProto ?? forwardedProto;
string verdict;
string? suggestedTrustedIp = null;
if (isHttps)
verdict = "ok";
else if (string.Equals(sentProto, "https", StringComparison.OrdinalIgnoreCase))
{
// Заголовок пришёл, но схема осталась http → непосредственный сосед не в доверенных.
verdict = "proxyNotTrusted";
suggestedTrustedIp = remoteIp;
}
else
verdict = "protoHeaderMissing";
return new ForwardingDiagnosticsDto(
scheme,
host,
$"{scheme}://{host}",
isHttps,
remoteIp,
forwardedProto,
Header(request, "X-Forwarded-For"),
Header(request, "X-Forwarded-Host"),
Header(request, "X-Real-IP"),
originalProto,
originalRemoteIp,
applied,
options.ForwardLimit,
options.ForwardedHeaders.ToString(),
[.. options.KnownProxies.Select(ip => ip.ToString())],
[.. options.KnownIPNetworks.Select(net => net.ToString())],
verdict,
suggestedTrustedIp
);
}
private static string? Header(HttpRequest request, string name) =>
request.Headers.TryGetValue(name, out var value) && !string.IsNullOrEmpty(value)
? value.ToString()
: null;
}
/// <summary>Ответ диагностики: процесс/рантайм, картина форвардинга и живое состояние инфраструктуры.</summary>
public sealed record DiagnosticsResponse(
HostDiagnosticsDto Host,
ForwardingDiagnosticsDto Forwarding,
SystemDiagnosticsDto System
);
public sealed record HostDiagnosticsDto(
string? AppVersion,
string Environment,
string Framework,
string Os,
DateTimeOffset ServerTimeUtc
);
/// <summary>
/// Картина X-Forwarded-*. <c>Verdict</c>: <c>ok</c> — схема https; <c>proxyNotTrusted</c> — прокси
/// прислал https, но <c>Applied=false</c>, и <c>SuggestedTrustedIp</c> нужно внести в KnownProxies;
/// <c>protoHeaderMissing</c> — прокси вообще не шлёт X-Forwarded-Proto.
/// </summary>
public sealed record ForwardingDiagnosticsDto(
string Scheme,
string Host,
string Origin,
bool IsHttps,
string? RemoteIp,
string? XForwardedProto,
string? XForwardedFor,
string? XForwardedHost,
string? XRealIp,
string? OriginalProto,
string? OriginalRemoteIp,
bool Applied,
int? ForwardLimit,
string ForwardedHeaders,
IReadOnlyList<string> KnownProxies,
IReadOnlyList<string> KnownNetworks,
string Verdict,
string? SuggestedTrustedIp
);
@@ -0,0 +1,77 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library.Genres;
using TeleWave.Application.Library.Genres.CreateGenre;
using TeleWave.Application.Library.Genres.DeleteGenre;
using TeleWave.Application.Library.Genres.ListGenres;
using TeleWave.Application.Library.Genres.UpdateGenre;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class GenreEndpoints
{
public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/genres")
.WithTags("Admin.Genres")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListGenres).Produces<IReadOnlyList<GenreDto>>();
admin.MapPost("", CreateGenre).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/{id:guid}", UpdateGenre).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteGenre).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListGenres(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListGenresQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> CreateGenre(
CreateGenreCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/genres/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateGenre(
Guid id,
UpdateGenreBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateGenreCommand(id, body.Name, body.SortOrder, body.Aliases),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteGenre(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteGenreCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateGenreBody(string Name, int SortOrder, IReadOnlyList<string>? Aliases);
@@ -0,0 +1,256 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Groups.AddGroupElements;
using TeleWave.Application.Programming.Groups.CreateGroup;
using TeleWave.Application.Programming.Groups.DeleteGroup;
using TeleWave.Application.Programming.Groups.ExcludeGroupElement;
using TeleWave.Application.Programming.Groups.FindGroupCandidates;
using TeleWave.Application.Programming.Groups.GetGroup;
using TeleWave.Application.Programming.Groups.ListGroups;
using TeleWave.Application.Programming.Groups.RemoveGroupItem;
using TeleWave.Application.Programming.Groups.ReorderGroup;
using TeleWave.Application.Programming.Groups.SetGroupItemWeight;
using TeleWave.Application.Programming.Groups.Suggest;
using TeleWave.Application.Programming.Groups.UpdateGroup;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class GroupEndpoints
{
public static IEndpointRouteBuilder MapGroupEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/groups")
.WithTags("Admin.Groups")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListGroups).Produces<IReadOnlyList<GroupSummaryDto>>();
admin.MapGet("/{id:guid}", GetGroup).Produces<GroupDto>();
admin.MapPost("", CreateGroup).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/{id:guid}", UpdateGroup).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteGroup).Produces(StatusCodes.Status204NoContent);
// Подбор по правилу набора: правило можно передать в теле, чтобы крутить его до сохранения.
admin
.MapPost("/{id:guid}/candidates", FindCandidates)
.Produces<IReadOnlyList<GroupCandidateDto>>();
// Предложения: что имеет смысл завести группой при нынешней библиотеке.
admin.MapGet("/suggestions", Suggestions).Produces<IReadOnlyList<GroupSuggestionDto>>();
admin
.MapPost("/suggestions", CreateFromSuggestion)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPost("/{id:guid}/items", AddElements).Produces<AddedCountResponse>();
admin
.MapDelete("/{id:guid}/items/{itemId:guid}", RemoveItem)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/items/{itemId:guid}/weight", SetWeight)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
// Поправки к вычисленному составу динамической группы.
admin.MapPost("/{id:guid}/exclusions", Exclude).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListGroups(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListGroupsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetGroup(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetGroupQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateGroup(
CreateGroupCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/groups/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> Suggestions(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SuggestGroupsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> CreateFromSuggestion(
CreateGroupFromSuggestionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateGroupFromSuggestionCommand(body.Key),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/groups/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateGroup(
Guid id,
UpdateGroupBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateGroupCommand(id, body.Name, body.Description, body.Filter, body.Mode),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteGroup(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteGroupCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> FindCandidates(
Guid id,
FindCandidatesBody? body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new FindGroupCandidatesQuery(id, body?.Filter),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddElements(
Guid id,
AddGroupElementsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddGroupElementsCommand(id, body.Elements),
cancellationToken
);
return result.IsSuccess
? Results.Ok(new AddedCountResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveItem(
Guid id,
Guid itemId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveGroupItemCommand(id, itemId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetWeight(
Guid id,
Guid itemId,
GroupItemWeightBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetGroupItemWeightCommand(id, itemId, body.Weight),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Exclude(
Guid id,
ExcludeElementBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ExcludeGroupElementCommand(id, body.ElementKind, body.ElementId, body.Excluded),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Reorder(
Guid id,
ReorderGroupBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ReorderGroupCommand(id, body.ItemIdsInOrder),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateGroupBody(
string Name,
string? Description,
GroupFilter? Filter,
GroupMode? Mode = null
);
/// <summary>Исключить элемент из динамической группы либо вернуть его правилу.</summary>
public sealed record ExcludeElementBody(
GroupElementKind ElementKind,
Guid ElementId,
bool Excluded
);
public sealed record FindCandidatesBody(GroupFilter? Filter);
public sealed record CreateGroupFromSuggestionBody(string Key);
public sealed record AddGroupElementsBody(IReadOnlyList<GroupElementRef> Elements);
public sealed record GroupItemWeightBody(int Weight);
public sealed record ReorderGroupBody(IReadOnlyList<Guid> ItemIdsInOrder);
/// <summary>Сколько позиций реально добавлено (уже входящие в группу пропускаются).</summary>
public sealed record AddedCountResponse(int Added);
@@ -0,0 +1,131 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Images;
using TeleWave.Application.Images.DeleteImage;
using TeleWave.Application.Images.GetImageFile;
using TeleWave.Application.Images.ListImages;
using TeleWave.Application.Images.UploadImage;
using TeleWave.Domain.Images;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ImageEndpoints
{
private const long MaxBytes = 50L * 1024 * 1024; // 50 МБ
// Тип поля — HashSet, а не IReadOnlySet: поле приватное, и через интерфейс Contains уходит
// в виртуальный вызов вместо прямого.
private static readonly HashSet<string> AllowedExtensions = new(
StringComparer.OrdinalIgnoreCase
)
{
".jpg",
".jpeg",
".png",
".webp",
".bmp",
".gif",
};
public static IEndpointRouteBuilder MapImageEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/images")
.WithTags("Admin.Images")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListImages).Produces<IReadOnlyList<ImageDto>>();
admin.MapPost("", UploadImage).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapDelete("/{id:guid}", DeleteImage).Produces(StatusCodes.Status204NoContent);
// Публичная отдача файла (для <img> у зрителя и в админке).
app.MapGet("/api/images/{id:guid}", ServeImage).WithTags("Images");
return app;
}
private static async Task<IResult> ListImages(
ImageCategory category,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListImagesQuery(category), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UploadImage(
ImageCategory category,
string fileName,
HttpRequest request,
IImageStore storage,
ISender sender,
CancellationToken cancellationToken
)
{
if (request.ContentLength is > MaxBytes or 0 or null)
return ImageErrors.InvalidFile.ToProblem();
var ext = Path.GetExtension(fileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return ImageErrors.InvalidFile.ToProblem();
var created = await sender.Send(
new UploadImageCommand(category, ext, fileName),
cancellationToken
);
if (!created.IsSuccess)
return created.ToHttpResult();
try
{
await storage.SaveAsync(created.Value, ext, request.Body, cancellationToken);
}
catch
{
// Файл не сохранился — не оставляем висячую запись реестра.
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
throw;
}
return Results.Created(
$"/api/images/{created.Value}",
new CreatedIdResponse(created.Value)
);
}
private static async Task<IResult> DeleteImage(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteImageCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ServeImage(
Guid id,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetImageFileQuery(id), cancellationToken);
if (!result.IsSuccess)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=86400";
return Results.File(result.Value, ContentTypeFor(Path.GetExtension(result.Value)));
}
private static string ContentTypeFor(string extension) =>
extension.ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
".gif" => "image/gif",
".bmp" => "image/bmp",
_ => "image/jpeg",
};
}
@@ -0,0 +1,64 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library.Interstitials;
using TeleWave.Application.Library.Interstitials.ImportInterstitials;
using TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
using TeleWave.Application.Library.Interstitials.ListInterstitials;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Ролики-врезки: тот же <c>Show(Kind = Interstitial)</c>, но со своим экраном (см. 6.7). Правка
/// названия и удаление идут через обычные эндпоинты шоу — здесь только то, чего у библиотеки нет:
/// список с длительностями, блоки и импорт загруженных файлов.
/// </summary>
public static class InterstitialEndpoints
{
public static IEndpointRouteBuilder MapInterstitialEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/interstitials")
.WithTags("Admin.Interstitials")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", List).Produces<IReadOnlyList<InterstitialDto>>();
admin.MapGet("/blocks", ListBlocks).Produces<IReadOnlyList<InterstitialBlockDto>>();
admin.MapPost("/import", Import).Produces<ImportInterstitialsResponse>();
return app;
}
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListInterstitialsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ListBlocks(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListInterstitialBlocksQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Import(
ImportInterstitialsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportInterstitialsCommand(body.MediaAssetIds),
cancellationToken
);
return result.IsSuccess
? Results.Ok(new ImportInterstitialsResponse(result.Value))
: result.ToHttpResult();
}
}
public sealed record ImportInterstitialsBody(IReadOnlyList<Guid> MediaAssetIds);
public sealed record ImportInterstitialsResponse(int Imported);
@@ -0,0 +1,241 @@
using System.Globalization;
using System.Text;
using System.Xml;
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming.GetIptvGuide;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Выгрузка для внешних IPTV-плееров: M3U со списком каналов и XMLTV с программой передач.
///
/// Плеер не умеет ни Bearer, ни httpOnly-cookie — он умеет только открыть URL. Поэтому пропуск
/// зашит в сами ссылки файла долгим stream-токеном: **файл и есть доступ к эфиру**, и делиться им
/// нельзя. Срок — <c>Storage:IptvTokenDays</c>; блокировка зрителя обрывает доступ на ближайшей
/// перезагрузке плейлиста канала, а смена <c>Jwt:SigningKey</c> обнуляет все выданные разом.
/// </summary>
public static class IptvEndpoints
{
/// <summary>Сколько суток программы класть в XMLTV: горизонт ленты и так неделя.</summary>
private static readonly TimeSpan GuideWindow = TimeSpan.FromDays(7);
/// <summary>Заглушка иконки канала, когда своя не задана: фавикон сайта из wwwroot.</summary>
private const string FaviconPath = "/favicon.svg";
public static IEndpointRouteBuilder MapIptvEndpoints(this IEndpointRouteBuilder app)
{
var iptv = app.MapGroup("/api/iptv").WithTags("IPTV");
// Файл забирает браузер под обычной авторизацией — токен внутрь кладёт уже сервер.
iptv.MapGet("/playlist.m3u", Playlist).RequireAuthorization();
// XMLTV тянет сам плеер, у него из пропусков только токен из ссылки.
iptv.MapGet("/epg.xml", Guide);
return app;
}
private static async Task<IResult> Playlist(
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Results.Unauthorized();
// Окно нулевое: плейлисту нужен только список каналов, программу плеер возьмёт из XMLTV.
var now = DateTimeOffset.UtcNow;
var channels = await sender.Send(new GetIptvGuideQuery(now, now), cancellationToken);
var (token, _) = tokens.IssueForIptv(userId);
var origin = Origin(request);
var body = RenderPlaylist(channels, origin, token);
return Results.File(
Encoding.UTF8.GetBytes(body),
"audio/x-mpegurl",
fileDownloadName: "telewave.m3u"
);
}
private static async Task<IResult> Guide(
HttpRequest request,
StreamTokenService tokens,
IIdentityService identity,
ISender sender,
CancellationToken cancellationToken
)
{
if (tokens.Validate(request.Query["token"]) is not { } userId)
return Results.Unauthorized();
// Та же сверка, что и у плейлиста канала: заблокированный зритель не должен продолжать
// тянуть гид по выданному раньше файлу.
var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked)
return Results.Unauthorized();
var now = DateTimeOffset.UtcNow;
var channels = await sender.Send(
new GetIptvGuideQuery(now, now.Add(GuideWindow)),
cancellationToken
);
return Results.File(
RenderGuide(channels, Origin(request)),
"application/xml; charset=utf-8",
fileDownloadName: "telewave-epg.xml"
);
}
/// <summary>
/// Абсолютный адрес сервиса. Плееру нужны именно абсолютные ссылки — относительные ему не от
/// чего считать. Схема и хост берутся из запроса: TLS внешний, и их подставляет
/// <c>ForwardedHeaders</c>.
/// </summary>
private static string Origin(HttpRequest request) => $"{request.Scheme}://{request.Host}";
private static string RenderPlaylist(
IReadOnlyList<IptvChannelDto> channels,
string origin,
string token
)
{
var sb = new StringBuilder();
sb.Append(
CultureInfo.InvariantCulture,
$"#EXTM3U x-tvg-url=\"{origin}/api/iptv/epg.xml?token={token}\"\n"
);
foreach (var channel in channels)
{
// tvg-logo ставим всегда: своя иконка либо фавикон-заглушка — иначе в плеере пустой квадрат.
var logo = $" tvg-logo=\"{ChannelIcon(origin, channel.IconImageId)}\"";
var number = channel.Number is { } value
? $" tvg-chno=\"{value.ToString(CultureInfo.InvariantCulture)}\""
: string.Empty;
var name = Attribute(channel.Name);
sb.Append(
CultureInfo.InvariantCulture,
$"#EXTINF:-1 tvg-id=\"{Attribute(channel.Slug)}\" tvg-name=\"{name}\"{number}{logo} group-title=\"TeleWave\",{name}\n"
);
sb.Append(
CultureInfo.InvariantCulture,
$"{origin}/api/channels/{channel.Slug}/live.m3u8?token={token}\n"
);
}
return sb.ToString();
}
/// <summary>
/// Значение атрибута M3U. Экранирования в формате нет вовсе, поэтому кавычки и переводы строк
/// не заменяются, а вырезаются: иначе название канала рвало бы строку файла.
/// </summary>
private static string Attribute(string value) =>
value.Replace("\"", string.Empty).ReplaceLineEndings(" ").Trim();
private static byte[] RenderGuide(IReadOnlyList<IptvChannelDto> channels, string origin)
{
using var output = new MemoryStream();
// Пишем сразу в поток в UTF-8, а не в StringBuilder: XmlWriter поверх строки объявил бы
// encoding="utf-16" (строки .NET — UTF-16), тогда как байты мы всё равно отдаём в UTF-8.
// Часть плееров (Go encoding/xml) отвергает такое расхождение декларации и реальных байт.
// BOM не пишем: XMLTV его не ждёт, и он мешает строгим парсерам.
// Через XmlWriter, а не склейкой строк: в названия и описания приходит текст из библиотеки,
// и экранировать его руками — верный способ однажды отдать битый XML.
using (
var writer = XmlWriter.Create(
output,
new XmlWriterSettings
{
Indent = true,
Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
}
)
)
{
writer.WriteStartDocument();
writer.WriteStartElement("tv");
writer.WriteAttributeString("generator-info-name", "TeleWave");
// Сначала все каналы, потом все передачи: XMLTV требует именно такого порядка секций.
foreach (var channel in channels)
WriteChannel(writer, channel, origin);
foreach (var channel in channels)
{
foreach (var programme in channel.Programmes)
WriteProgramme(writer, channel.Slug, programme, origin);
}
writer.WriteEndElement();
writer.WriteEndDocument();
}
return output.ToArray();
}
private static void WriteChannel(XmlWriter writer, IptvChannelDto channel, string origin)
{
writer.WriteStartElement("channel");
writer.WriteAttributeString("id", channel.Slug);
writer.WriteElementString("display-name", channel.Name);
// Номер вторым display-name: так плееры с нумерацией подхватывают его без своих настроек.
if (channel.Number is { } number)
writer.WriteElementString(
"display-name",
number.ToString(CultureInfo.InvariantCulture)
);
// Иконку канала пишем всегда: своя либо фавикон-заглушка (в отличие от иконок передач ниже).
writer.WriteStartElement("icon");
writer.WriteAttributeString("src", ChannelIcon(origin, channel.IconImageId));
writer.WriteEndElement();
writer.WriteEndElement();
}
private static void WriteProgramme(
XmlWriter writer,
string slug,
IptvProgrammeDto programme,
string origin
)
{
writer.WriteStartElement("programme");
writer.WriteAttributeString("start", XmltvTime(programme.StartsAtUtc));
writer.WriteAttributeString("stop", XmltvTime(programme.EndsAtUtc));
writer.WriteAttributeString("channel", slug);
writer.WriteElementString("title", programme.Title);
if (!string.IsNullOrWhiteSpace(programme.SubTitle))
writer.WriteElementString("sub-title", programme.SubTitle);
if (!string.IsNullOrWhiteSpace(programme.Description))
writer.WriteElementString("desc", programme.Description);
WriteIcon(writer, programme.ImageId, origin);
writer.WriteEndElement();
}
private static void WriteIcon(XmlWriter writer, Guid? imageId, string origin)
{
if (imageId is not { } id)
return;
writer.WriteStartElement("icon");
writer.WriteAttributeString("src", $"{origin}/api/images/{id}");
writer.WriteEndElement();
}
/// <summary>URL иконки канала: своя из реестра либо фавикон-заглушка сайта (абсолютный адрес).</summary>
private static string ChannelIcon(string origin, Guid? iconImageId) =>
iconImageId is { } id ? $"{origin}/api/images/{id}" : $"{origin}{FaviconPath}";
/// <summary>Время XMLTV: <c>YYYYMMDDHHMMSS +0000</c>. Отдаём в UTC — плеер сдвинет сам.</summary>
private static string XmltvTime(DateTimeOffset moment) =>
moment.ToUniversalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)
+ " +0000";
}
@@ -0,0 +1,156 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Templates.Junctions;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Шаблоны стыков: что играет между программами. Стыки общие для всех каналов, канал только
/// ссылается на них слотами. Как и правка сетки, эфира не двигают — помечают шаблоны каналов,
/// которые их используют, изменёнными, а хвост пересобирается применением.
/// </summary>
public static class JunctionEndpoints
{
public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/junctions")
.WithTags("Admin.Junctions")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", List).Produces<IReadOnlyList<JunctionTemplateDto>>();
admin.MapPost("", Create).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/{junctionId:guid}", Update).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{junctionId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{junctionId:guid}/elements", AddElement)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{junctionId:guid}/elements/{elementId:guid}", UpdateElement)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{junctionId:guid}/elements/{elementId:guid}", RemoveElement)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{junctionId:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListJunctionsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Create(
JunctionNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CreateJunctionCommand(body.Name), cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/junctions/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> Update(
Guid junctionId,
UpdateJunctionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateJunctionCommand(junctionId, body.Name, body.MaxTotalSeconds),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Delete(
Guid junctionId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteJunctionCommand(junctionId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddElement(
Guid junctionId,
JunctionElementKindBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddJunctionElementCommand(junctionId, body.Kind),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/junctions/{junctionId}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateElement(
Guid junctionId,
Guid elementId,
JunctionElementInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateJunctionElementCommand(junctionId, elementId, input),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveElement(
Guid junctionId,
Guid elementId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveJunctionElementCommand(junctionId, elementId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Reorder(
Guid junctionId,
ReorderJunctionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ReorderJunctionCommand(junctionId, body.Order),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record JunctionNameBody(string Name);
public sealed record UpdateJunctionBody(string Name, int? MaxTotalSeconds);
public sealed record JunctionElementKindBody(JunctionElementKind Kind);
/// <summary>Порядок врезок вместе с их развилками — перетаскивание меняет и то, и другое разом.</summary>
public sealed record ReorderJunctionBody(IReadOnlyList<JunctionElementOrder> Order);
@@ -0,0 +1,52 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Maintenance.ClearAllMedia;
using TeleWave.Application.Maintenance.DeleteAllShows;
using TeleWave.Application.Maintenance.DeleteShowMedia;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class MaintenanceEndpoints
{
public static IEndpointRouteBuilder MapMaintenanceEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/maintenance")
.WithTags("Admin.Maintenance")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("/clear-media", ClearMedia).Produces<int>();
admin.MapPost("/clear-shows", ClearShows).Produces<int>();
admin.MapPost("/shows/{showId:guid}/clear-media", ClearShowMedia).Produces<int>();
return app;
}
private static async Task<IResult> ClearMedia(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ClearAllMediaCommand(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearShows(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteAllShowsCommand(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearShowMedia(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowMediaCommand(showId), cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,306 @@
using System.Text;
using LiteCqrs;
using Microsoft.Extensions.Options;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Media;
using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.MovieImport;
using TeleWave.Application.Media.Register;
using TeleWave.Application.Media.Retry;
using TeleWave.Application.Media.Stats;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
public static class MediaEndpoints
{
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/media")
.WithTags("Admin.Media")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
// Перезапуск упавшей обработки: файл на месте, повторить нарезку — обычное дело.
admin.MapPost("/{id:guid}/retry", Retry).Produces<RetryMediaResultDto>();
admin.MapPost("/retry-failed", RetryFailed).Produces<RetryMediaResultDto>();
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
// Разбор фильмов: имена приходят с клиента (каталог manual/ либо выбранные в браузере
// файлы), сервер разбирает их и ищет в источнике. Импорт заводит шоу на файл.
admin.MapPost("/movies/match", MatchMovies).Produces<IReadOnlyList<MovieMatchDto>>();
admin.MapPost("/movies/match-one", MatchMovie).Produces<MovieMatchDto>();
admin.MapPost("/movies/import", ImportMovies).Produces<ImportMoviesResultDto>();
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
return app;
}
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>
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
/// </summary>
private static async Task<IResult> Upload(
string fileName,
HttpRequest request,
IMediaStorage storage,
IMediaProcessingQueue queue,
ISender sender,
UploadLimits limits,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(fileName))
return Results.Problem(
title: MediaErrors.EmptyFileName.Code,
detail: MediaErrors.EmptyFileName.Message,
statusCode: StatusCodes.Status400BadRequest
);
if (!MediaFormats.IsAllowed(fileName))
return Results.Problem(
title: MediaErrors.UnsupportedFormat.Code,
detail: MediaErrors.UnsupportedFormat.Message,
statusCode: StatusCodes.Status400BadRequest
);
var contentLength = request.ContentLength ?? 0;
if (contentLength > limits.MaxUploadBytes)
return Results.Problem(
title: MediaErrors.FileTooLarge.Code,
detail: MediaErrors.FileTooLarge.Message,
statusCode: StatusCodes.Status400BadRequest
);
var free = storage.GetAvailableFreeSpaceBytes();
if (free - contentLength < limits.MinFreeSpaceBytes)
return Results.Problem(
title: MediaErrors.InsufficientStorage.Code,
detail: MediaErrors.InsufficientStorage.Message,
statusCode: StatusCodes.Status409Conflict
);
var extension = Path.GetExtension(fileName);
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
// Файл уже на диске в uploads/, но в БД его ещё нет и ссылок на токен нигде не остаётся:
// любой исход, кроме успешной регистрации, обязан за собой убрать. Удаляем и при исключении
// (оборванный запрос, сбой БД) — иначе гигабайты остаются в uploads/ до уборщика.
// Токен отмены для уборки не пробрасываем: при обрыве запроса он уже сработал.
Result<Guid> result;
try
{
result = await sender.Send(
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
cancellationToken
);
}
catch
{
await storage.DeleteUploadAsync(token, CancellationToken.None);
throw;
}
if (!result.IsSuccess)
{
await storage.DeleteUploadAsync(token, CancellationToken.None);
return result.ToHttpResult();
}
queue.Enqueue(result.Value);
return Results.Created(
$"/api/admin/media/{result.Value}",
new UploadMediaResponse(result.Value)
);
}
private static async Task<IResult> List(
[AsParameters] ListMediaFilter filter,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ListMediaAssetsQuery(
filter.Page is > 0 ? filter.Page.Value : 1,
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
filter.Status ?? [],
filter.Search,
filter.Sort,
filter.Desc ?? false
),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Delete(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListManual(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> MatchMovies(
MatchMoviesBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new MatchMoviesQuery(body.Names, body.Provider),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> MatchMovie(
MatchMovieBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new MatchMovieQuery(body.Name, body.Title, body.Year, body.Provider),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> ImportMovies(
ImportMoviesBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportMoviesCommand(body.Items, body.Provider),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ImportManual(
ImportManualInboxBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportManualInboxCommand(body.Items, body.ShowId),
cancellationToken
);
return result.ToHttpResult();
}
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
{
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
return Results.NotFound();
var baseUrl = $"/api/admin/media/{id}/preview/";
var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
continue;
// Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
.Append('\n');
}
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
}
private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
{
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
}
/// <summary>
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
/// </summary>
public sealed record ListMediaFilter(
int? Page,
int? PageSize,
MediaAssetStatus[]? Status,
string? Search,
string? Sort,
bool? Desc
);
public sealed record UploadMediaResponse(Guid Id);
public sealed record 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);
@@ -0,0 +1,160 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Metadata;
using TeleWave.Application.Metadata.ApplyShowMetadata;
using TeleWave.Application.Metadata.ClearShowMetadata;
using TeleWave.Application.Metadata.FindMissingEpisodes;
using TeleWave.Application.Metadata.FindMissingSeasons;
using TeleWave.Application.Metadata.GetProviders;
using TeleWave.Application.Metadata.RefreshEpisodes;
using TeleWave.Application.Metadata.SearchShows;
using TeleWave.Application.Metadata.SetShowPoster;
using TeleWave.Application.Metadata.UpdateShowMetadata;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class MetadataEndpoints
{
public static IEndpointRouteBuilder MapMetadataEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/metadata")
.WithTags("Admin.Metadata")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/providers", GetProviders).Produces<IReadOnlyList<string>>();
admin.MapGet("/search", Search).Produces<IReadOnlyList<MetadataCandidate>>();
admin.MapPost("/shows/{showId:guid}/apply", Apply).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/shows/{showId:guid}", Clear).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/shows/{showId:guid}/poster-image", SetPosterImage)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces<int>();
admin
.MapGet("/shows/{showId:guid}/missing-episodes", FindMissing)
.Produces<MissingEpisodesReport>();
admin
.MapGet("/shows/{showId:guid}/missing-seasons", FindMissingSeasons)
.Produces<MissingSeasonsReport>();
// Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}.
return app;
}
private static async Task<IResult> GetProviders(
ISender sender,
CancellationToken cancellationToken
)
{
var providers = await sender.Send(new GetMetadataProvidersQuery(), cancellationToken);
return Results.Ok(providers);
}
private static async Task<IResult> Search(
string provider,
string query,
ShowKind kind,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SearchShowMetadataQuery(provider, query, kind),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Apply(
Guid showId,
ApplyMetadataBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ApplyShowMetadataCommand(showId, body.Provider, body.ExternalId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Update(
Guid showId,
UpdateMetadataBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateShowMetadataCommand(showId, body.Description, body.Year),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Clear(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ClearShowMetadataCommand(showId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetPosterImage(
Guid showId,
SetPosterImageBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowPosterCommand(showId, body.ImageId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RefreshEpisodes(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RefreshShowEpisodesMetadataCommand(showId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> FindMissing(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new FindMissingEpisodesQuery(showId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> FindMissingSeasons(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new FindMissingSeasonsQuery(showId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record ApplyMetadataBody(string Provider, string ExternalId);
public sealed record UpdateMetadataBody(string? Description, int? Year);
public sealed record SetPosterImageBody(Guid? ImageId);
@@ -0,0 +1,89 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Admin.Roles.ChangeUserRole;
using TeleWave.Application.Admin.Roles.CreateRole;
using TeleWave.Application.Admin.Roles.DeleteRole;
using TeleWave.Application.Admin.Roles.ListRoles;
using TeleWave.Application.Admin.Roles.UpdateRole;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class RoleEndpoints
{
public static IEndpointRouteBuilder MapRoleEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Roles")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/roles", ListRoles).Produces<IReadOnlyList<RoleDto>>();
admin.MapPost("/roles", CreateRole).Produces<RoleDto>();
admin.MapPut("/roles/{id:guid}", UpdateRole).Produces<RoleDto>();
admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/role", ChangeUserRole)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListRoles(
ISender sender,
CancellationToken cancellationToken
)
{
var roles = await sender.Send(new ListRolesQuery(), cancellationToken);
return Results.Ok(roles);
}
private static async Task<IResult> CreateRole(
CreateRoleCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateRole(
Guid id,
UpdateRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UpdateRoleCommand(id, body.Name), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteRole(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserRole(
Guid id,
ChangeUserRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ChangeUserRoleCommand(id, body.RoleId),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateRoleBody(string Name);
public sealed record ChangeUserRoleBody(Guid RoleId);
@@ -0,0 +1,55 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Settings;
using TeleWave.Application.Settings.GetSiteSettings;
using TeleWave.Application.Settings.UpdateSiteSettings;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class SettingsEndpoints
{
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/settings")
.WithTags("Admin.Settings")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetSettings(
ISender sender,
CancellationToken cancellationToken
)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(settings);
}
private static async Task<IResult> UpdateSettings(
UpdateSiteSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateSiteSettingsCommand(
body.RegistrationEnabled,
body.PreferredAudioLanguages ?? "",
body.ChannelNumbersEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateSiteSettingsBody(
bool RegistrationEnabled,
string? PreferredAudioLanguages,
bool ChannelNumbersEnabled
);
@@ -0,0 +1,259 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library;
using TeleWave.Application.Library.AddEpisode;
using TeleWave.Application.Library.BulkTagShows;
using TeleWave.Application.Library.CreateShow;
using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Library.GetShowUsage;
using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Application.Library.RenameShow;
using TeleWave.Application.Library.SetShowAudience;
using TeleWave.Application.Library.SetShowGenres;
using TeleWave.Application.Library.SetShowOriginalName;
using TeleWave.Application.Metadata.EnrichShows;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ShowEndpoints
{
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/shows")
.WithTags("Admin.Shows")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/original-name", SetOriginalName)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
admin.MapGet("/{id:guid}/usage", GetUsage).Produces<ShowUsageDto>();
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
// Массовые операции над отмеченными шоу: разметка руками и обогащение из источника.
admin.MapPost("/bulk/tag", BulkTag).Produces<BulkTaggedResponse>();
admin.MapPost("/bulk/enrich", BulkEnrich).Produces<EnrichShowsResultDto>();
admin
.MapPost("/{id:guid}/episodes", AddEpisode)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> CreateShow(
CreateShowCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/shows/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListShows(
ISender sender,
CancellationToken cancellationToken,
Guid? genreId = null,
bool interstitials = false
)
{
var result = await sender.Send(
new ListShowsQuery(genreId, interstitials),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> GetShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Rename(
Guid id,
RenameShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetOriginalName(
Guid id,
SetShowOriginalNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowOriginalNameCommand(id, body.OriginalName),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetAudience(
Guid id,
SetShowAudienceBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowAudienceCommand(id, body.Audience),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetGenres(
Guid id,
SetShowGenresBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
cancellationToken
);
return result.ToHttpResult();
}
/// <param name="withMedia">Снести заодно файлы шоу — иначе они останутся осиротевшими.</param>
/// <param name="withSchedule">Вырезать шоу из будущего эфира вместо отказа.</param>
private static async Task<IResult> DeleteShow(
Guid id,
ISender sender,
CancellationToken cancellationToken,
bool withMedia = false,
bool withSchedule = false
)
{
var result = await sender.Send(
new DeleteShowCommand(id, withMedia, withSchedule),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddEpisode(
Guid id,
AddEpisodeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddEpisodeCommand(id, body.MediaAssetId),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveEpisode(
Guid id,
Guid episodeId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
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);
/// <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 SetShowOriginalNameBody(string? OriginalName);
/// <summary>Рейтинг шоу; null — снять проставленный.</summary>
public sealed record SetShowAudienceBody(ShowAudience? Audience);
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
@@ -0,0 +1,33 @@
using LiteCqrs;
using TeleWave.Application.Storage;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>Чем занят диск: объём по областям хранилища и заполненность тома.</summary>
public static class StorageEndpoints
{
public static IEndpointRouteBuilder MapStorageEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/admin/storage", Stats)
.WithTags("Admin.Storage")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))
.Produces<StorageStatsDto>();
return app;
}
/// <param name="refresh">Пересчитать, не дожидаясь истечения кэша: обход дерева долгий.</param>
private static async Task<IResult> Stats(
bool? refresh,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new GetStorageStatsQuery(refresh ?? false),
cancellationToken
);
return Results.Ok(result);
}
}
@@ -0,0 +1,214 @@
using System.Globalization;
using System.Text;
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Application.Streaming.GetLivePlaylist;
using TeleWave.Application.Streaming.GetPublicEpg;
using TeleWave.Application.Streaming.ListPublicChannels;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints
{
private const string StreamCookieName = "tw_stream";
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
{
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
channels.MapGet("/{slug}/epg", Epg);
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
return app;
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ViewerFeatures(
ISiteSettings siteSettings,
CancellationToken cancellationToken
) =>
Results.Ok(
new ViewerFeaturesDto(
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
)
);
/// <summary>
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
/// </summary>
private static IResult Watch(
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
HttpResponse response,
IHostEnvironment env
)
{
if (currentUser.UserId is not { } userId)
return Results.Unauthorized();
var (token, expiresAt) = tokens.Issue(userId);
response.Cookies.Append(
StreamCookieName,
token,
new CookieOptions
{
HttpOnly = true,
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
Secure = !env.IsDevelopment() || request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api",
Expires = expiresAt,
}
);
return Results.NoContent();
}
private static async Task<IResult> Epg(
string slug,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddHours(12);
var result = await sender.Send(
new GetPublicEpgQuery(slug, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> LivePlaylist(
string slug,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
IIdentityService identity,
ISender sender,
CancellationToken cancellationToken
)
{
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
// Для IPTV это единственная точка, где блокировка вообще может сработать: у внешнего плеера
// пропуск долгий и перевыпуску не подлежит.
if (Viewer(request, tokens) is not { } userId)
return Results.Unauthorized();
var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked)
return Results.Unauthorized();
var result = await sender.Send(
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
cancellationToken
);
if (!result.IsSuccess)
return result.ToHttpResult();
if (result.Value.Segments.Count == 0)
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";
return Results.Text(Render(result.Value, suffix), "application/vnd.apple.mpegurl");
}
private static IResult Segment(
Guid assetId,
string file,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
MediaPathResolver paths
)
{
if (Viewer(request, tokens) is null)
return Results.Unauthorized();
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
/// <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 sb = new StringBuilder();
sb.Append("#EXTM3U\n");
sb.Append("#EXT-X-VERSION:3\n");
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
);
sb.Append(
CultureInfo.InvariantCulture,
$"#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)
{
if (segment.Discontinuity)
sb.Append("#EXT-X-DISCONTINUITY\n");
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
sb.Append(
CultureInfo.InvariantCulture,
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts{segmentSuffix}\n"
);
}
return sb.ToString();
}
}
/// <summary>Опции зрительской части, включённые глобально.</summary>
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
@@ -0,0 +1,175 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Notifications;
using TeleWave.Application.Notifications.Link;
using TeleWave.Application.Notifications.Settings;
using TeleWave.Application.Notifications.Subscribers;
using TeleWave.Domain.Notifications;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Бот расписания: настройка в админке, привязка чата зрителем и приём обновлений вебхуком.
/// </summary>
public static class TelegramEndpoints
{
/// <summary>Заголовок, которым Telegram подтверждает, что вызов вебхука — его.</summary>
private const string SecretHeader = "X-Telegram-Bot-Api-Secret-Token";
public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/telegram")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/settings", GetSettings).Produces<TelegramSettingsDto>();
admin.MapPut("/settings", UpdateSettings).Produces<TelegramSettingsDto>();
admin
.MapGet("/subscribers", ListSubscribers)
.Produces<IReadOnlyList<TelegramSubscriberDto>>();
// Настроен ли бот — публично: по этому флагу зрительская страница решает, показывать ли
// кнопку. Ничего, кроме флага и имени бота, здесь не отдаётся.
app.MapGet("/api/telegram/status", Status).AllowAnonymous().Produces<TelegramStatusDto>();
// Ссылка на чат — зрителю, а не админу: код привязывает его собственную учётку.
app.MapPost("/api/telegram/link", IssueLink)
.RequireAuthorization()
.Produces<TelegramLinkDto>();
// Вебхук открыт наружу по построению: его вызывает Telegram, а не наш фронт. Пускаем
// только по секрету из настроек — адрес рано или поздно окажется в чужих логах.
app.MapPost("/api/telegram/webhook", Webhook).AllowAnonymous();
return app;
}
private static async Task<IResult> Status(
IAppDbContext dbContext,
CancellationToken cancellationToken
)
{
var settings = await dbContext
.TelegramSettings.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken);
var link = settings is null ? null : TelegramSettingsService.BotLink(settings);
return Results.Ok(new TelegramStatusDto(link is not null, settings?.BotUsername));
}
private static async Task<IResult> GetSettings(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetTelegramSettingsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
TelegramSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateTelegramSettingsCommand(
body.IsEnabled,
body.Transport,
body.BotToken,
body.WebhookUrl,
body.ProxyKind,
body.ProxyHost,
body.ProxyPort,
body.ProxyUsername,
body.ProxyPassword
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ListSubscribers(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListTelegramSubscribersQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> IssueLink(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new IssueTelegramLinkCommand(), cancellationToken);
return result.ToHttpResult();
}
/// <summary>
/// Приём обновления от Telegram. Разбираем тем же сервисом, что и опрос: транспорт разный,
/// разговор один.
///
/// Отвечаем 200 всегда, когда секрет сошёлся: на ошибку Telegram повторяет доставку, и одно
/// кривое обновление зациклило бы вебхук.
/// </summary>
private static async Task<IResult> Webhook(
HttpRequest request,
IAppDbContext dbContext,
TelegramBotService bot,
ITelegramApi api,
ILoggerFactory loggers,
CancellationToken cancellationToken
)
{
var settings = await dbContext.TelegramSettings.FirstOrDefaultAsync(cancellationToken);
if (
settings is null
|| !settings.IsOperational
|| settings.Transport != TelegramTransport.Webhook
)
return Results.NotFound();
var secret = request.Headers[SecretHeader].ToString();
if (string.IsNullOrWhiteSpace(settings.WebhookSecret) || secret != settings.WebhookSecret)
return Results.Unauthorized();
var update = await api.ReadUpdateAsync(request.Body, cancellationToken);
if (update is null)
return Results.Ok();
var logger = loggers.CreateLogger("Telegram.Webhook");
try
{
await bot.HandleAsync(settings, update, DateTimeOffset.UtcNow, cancellationToken);
settings.MarkContact(DateTimeOffset.UtcNow);
settings.AdvanceUpdateId(update.UpdateId);
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogError(exception, "Не разобрано обновление {UpdateId}", update.UpdateId);
}
return Results.Ok();
}
}
/// <summary>Что зрительской части нужно знать о боте: настроен ли и как он называется.</summary>
public sealed record TelegramStatusDto(bool IsConfigured, string? BotUsername);
/// <summary>Тело правки настроек: секреты необязательны — пусто значит «не менять».</summary>
public sealed record TelegramSettingsBody(
bool IsEnabled,
TelegramTransport Transport,
string? BotToken,
string? WebhookUrl,
TelegramProxyKind ProxyKind,
string? ProxyHost,
int? ProxyPort,
string? ProxyUsername,
string? ProxyPassword
);
@@ -0,0 +1,434 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Application.Programming.Planning.Diff;
using TeleWave.Application.Programming.Planning.Preview;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.CreateTemplate;
using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.Reset;
using TeleWave.Application.Programming.Templates.Restore;
using TeleWave.Application.Programming.Templates.Transfer;
using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
/// изменённым, а хвост пересобирается отдельной командой применения.
/// </summary>
public static class TemplateEndpoints
{
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Templates")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin
.MapGet("/channels/{channelId:guid}/template", GetTemplate)
.Produces<ScheduleTemplateDto>();
// Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации).
admin
.MapPost("/channels/{channelId:guid}/template", CreateTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/templates/{templateId:guid}", UpdateTemplate)
.Produces(StatusCodes.Status204NoContent);
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
admin
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
.Produces<ApplyResultDto>();
// Откат правок к состоянию, по которому идёт эфир. Ленту не трогает — она и так по нему.
admin
.MapPost("/channels/{channelId:guid}/template/restore", RestoreTemplate)
.Produces<RestoreTemplateResultDto>();
// Сброс сетки вместе с собранным по ней будущим эфиром. Прошлое и идущая запись остаются.
admin
.MapPost("/channels/{channelId:guid}/template/reset", ResetTemplate)
.Produces<ResetTemplateResultDto>();
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
admin
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
.Produces<SchedulePreviewDto>();
// Проверки по правилам — только по шаблону, без прогона генератора.
admin
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
.Produces<IReadOnlyList<TemplateIssueDto>>();
// Что изменится в эфире, если применить прямо сейчас.
admin
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
.Produces<ScheduleDiffDto>();
admin
.MapPost(
"/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}",
CopyTemplate
)
.Produces<CopyTemplateResultDto>();
// Автосборка сетки: каталог профилей, план по выбранному и его создание. План считается
// одним и тем же кодом, поэтому предпросмотр показывает ровно то, что создаст кнопка.
admin
.MapGet("/grid-profiles", ListGridProfiles)
.Produces<IReadOnlyList<GridProfileDto>>();
admin
.MapGet("/channels/{channelId:guid}/template/grid-plan", PreviewGrid)
.Produces<GridPlanDto>();
admin
.MapPost("/channels/{channelId:guid}/template/generate", GenerateGrid)
.Produces<GenerateGridResultDto>();
// Обмен конфигурацией: выгрузка файлом, загрузка чужого файла и сборка запроса к ИИ.
// Запрос никуда не отправляется — админ копирует его в свою модель и приносит ответ назад.
admin
.MapGet("/channels/{channelId:guid}/template/export", ExportGrid)
.Produces<GridConfig>();
admin
.MapPost("/channels/{channelId:guid}/template/import", ImportGrid)
.Produces<GridImportResultDto>();
admin
.MapPost("/channels/{channelId:guid}/template/ai-prompt", BuildPrompt)
.Produces<GridPromptDto>();
admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/layers/{layerId:guid}", UpdateLayer)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/layers/{layerId:guid}", DeleteLayer)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/layers/{layerId:guid}/slots", CreateSlot)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/slots/{slotId:guid}", DeleteSlot)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateChannelTemplateCommand(channelId),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{channelId}/template",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ApplyTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ApplyChannelTemplateCommand(channelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RestoreTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RestoreTemplateCommand(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ResetTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ResetTemplateCommand(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> PreviewTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken,
int days = 1
)
{
var result = await sender.Send(
new PreviewScheduleQuery(channelId, days),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ValidateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DiffTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListGridProfiles(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListGridProfilesQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> PreviewGrid(
Guid channelId,
GridProfileKind profile,
GridGenerationMode mode,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new PreviewGeneratedGridQuery(channelId, profile, mode),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GenerateGrid(
Guid channelId,
GenerateGridBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new GenerateGridCommand(channelId, body.Profile, body.Mode),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ExportGrid(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ExportGridQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ImportGrid(
Guid channelId,
ImportGridBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportGridCommand(channelId, body.Config, body.Replace),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> BuildPrompt(
Guid channelId,
GridPromptBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new BuildGridPromptQuery(channelId, body.References ?? [], body.Notes),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> CopyTemplate(
Guid channelId,
Guid targetChannelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CopyTemplateCommand(channelId, targetChannelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTemplate(
Guid templateId,
UpdateTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateTemplateCommand(
templateId,
body.Name,
body.FallbackGroupId,
body.DefaultJunctionId,
body.Rules
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> CreateLayer(
Guid templateId,
CreateLayerBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateLayerCommand(templateId, body.Name, body.Priority),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/layers/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateLayer(
Guid layerId,
UpdateLayerBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateLayerCommand(
layerId,
body.Name,
body.Priority,
body.Applicability,
body.IsEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteLayer(
Guid layerId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateSlot(
Guid layerId,
SlotInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/slots/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateSlot(
Guid slotId,
SlotInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteSlot(
Guid slotId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateTemplateBody(
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
PlanningRules? Rules
);
public sealed record GenerateGridBody(GridProfileKind Profile, GridGenerationMode Mode);
public sealed record ImportGridBody(GridConfig Config, bool Replace);
public sealed record GridPromptBody(IReadOnlyList<string>? References, string? Notes);
public sealed record CreateLayerBody(string Name, int Priority);
public sealed record UpdateLayerBody(
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
);
+162
View File
@@ -0,0 +1,162 @@
using System.Net;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Scalar.AspNetCore;
using Serilog;
using TeleWave.Api.Common;
using TeleWave.Api.Endpoints;
using TeleWave.Application;
using TeleWave.Infrastructure;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args);
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
builder.WebHost.ConfigureKestrel(options =>
options.Limits.MaxRequestBodySize =
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog(
(services, configuration) =>
configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (var proxy in ReadList(builder.Configuration, "ForwardedHeaders:KnownProxies"))
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (var network in ReadList(builder.Configuration, "ForwardedHeaders:KnownNetworks"))
{
var parts = network.Split('/');
options.KnownIPNetworks.Add(
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.AddSingleton<UploadLimits>();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
builder.Services.AddRateLimiter(options =>
{
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
options.AddPolicy(
RateLimiting.AuthPolicy,
httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = authPermitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}
)
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
);
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync();
app.UseForwardedHeaders();
app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
// не раскрывать полную карту эндпоинтов без необходимости.
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.MapHealthChecks("/health");
app.MapAuthEndpoints();
app.MapRoleEndpoints();
app.MapAdminUserEndpoints();
app.MapMediaEndpoints();
app.MapShowEndpoints();
app.MapGenreEndpoints();
app.MapInterstitialEndpoints();
app.MapCollectionEndpoints();
app.MapGroupEndpoints();
app.MapTemplateEndpoints();
app.MapBumperEndpoints();
app.MapJunctionEndpoints();
app.MapChannelEndpoints();
app.MapTelegramEndpoints();
app.MapStreamingEndpoints();
app.MapIptvEndpoints();
app.MapMaintenanceEndpoints();
app.MapStorageEndpoints();
app.MapSettingsEndpoints();
app.MapMetadataEndpoints();
app.MapImageEndpoints();
app.MapDiagnosticsEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
await app.RunAsync();
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\TeleWave.Infrastructure\TeleWave.Infrastructure.csproj" />
<ProjectReference Include="..\TeleWave.Application\TeleWave.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
@@ -0,0 +1,29 @@
{
// Локальная БД разработчика. В appsettings.json строки подключения нет намеренно: этот файл
// едет в образ, и креды в нём (даже заведомо игрушечные) — это и находка сканера, и приглашение
// однажды поправить их «на месте» вместо ConnectionStrings__Default из окружения.
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Jwt": {
"SigningKey": "telewave-development-only-signing-key-not-for-production-0001"
},
"AdminSeed": {
"Username": "admin",
"Password": "Passw0rd!Dev"
},
"Storage": {
"RootPath": ".dev-media"
},
// Отладочные дампы каналов ложатся в папку репозитория: разбирать «почему сетка построилась
// так» приходится рядом с кодом. Путь относительный — от рабочего каталога Api (backend/src/TeleWave.Api).
"Debug": {
"ExportDirectory": "../../../debug-exports"
}
}
+69
View File
@@ -0,0 +1,69 @@
{
"Jwt": {
"Issuer": "TeleWave",
"Audience": "TeleWave",
"SigningKey": "change-me-min-32-chars-random-secret",
"AccessTokenMinutes": 15,
"RefreshTokenDays": 30
},
"AdminSeed": {
"Username": "",
"Password": ""
},
"Storage": {
"RootPath": "/media",
"SegmentSeconds": 2,
"LiveWindowSegments": 10,
"KeepOriginals": false,
"MinFreeSpaceBytes": 10737418240
},
"Scheduler": {
"HorizonDays": 7,
"RetentionDays": 90,
"TickMinutes": 30
},
"Media": {
"FfmpegPath": "ffmpeg",
"FfprobePath": "ffprobe",
"MaxUploadBytes": 21474836480,
"TranscodeThreads": 3,
"InboxScanSeconds": 15,
"NormalizeLoudness": true,
"LoudnessTargetLufs": -16
},
"Bumpers": {
"Width": 1280,
"Height": 720,
"FontFileSans": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"FontFileSerif": "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
"TemplateVersion": 1
},
"Metadata": {
"Language": "ru-RU",
"Tmdb": {
"ApiKey": "",
"BaseUrl": "https://api.themoviedb.org/3",
"ImageBaseUrl": "https://image.tmdb.org/t/p",
"PosterSize": "w500",
"StillSize": "w300"
},
"Omdb": {
"ApiKey": "",
"BaseUrl": "https://www.omdbapi.com"
}
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"WriteTo": [ { "Name": "Console" } ],
"Enrich": [ "FromLogContext" ]
},
"AllowedHosts": "*"
}
@@ -0,0 +1,7 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8" /><title>TeleWave</title></head>
<body>
<p>Frontend build not present yet — run the Vite build to populate wwwroot.</p>
</body>
</html>
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.ChangeUserRole;
public sealed record ChangeUserRoleCommand(Guid UserId, Guid RoleId) : ICommand<Result>;
@@ -0,0 +1,14 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.ChangeUserRole;
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService)
: ICommandHandler<ChangeUserRoleCommand, Result>
{
public Task<Result> Handle(
ChangeUserRoleCommand command,
CancellationToken cancellationToken
) => roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken);
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.CreateRole;
public sealed record CreateRoleCommand(string Name) : ICommand<Result<RoleDto>>;
@@ -0,0 +1,14 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.CreateRole;
public sealed class CreateRoleCommandHandler(IRoleService roleService)
: ICommandHandler<CreateRoleCommand, Result<RoleDto>>
{
public Task<Result<RoleDto>> Handle(
CreateRoleCommand command,
CancellationToken cancellationToken
) => roleService.CreateRoleAsync(command.Name, cancellationToken);
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Roles.CreateRole;
public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCommand>
{
public CreateRoleCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.DeleteRole;
public sealed record DeleteRoleCommand(Guid Id) : ICommand<Result>;
@@ -0,0 +1,12 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.DeleteRole;
public sealed class DeleteRoleCommandHandler(IRoleService roleService)
: ICommandHandler<DeleteRoleCommand, Result>
{
public Task<Result> Handle(DeleteRoleCommand command, CancellationToken cancellationToken) =>
roleService.DeleteRoleAsync(command.Id, cancellationToken);
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Admin.Roles.ListRoles;
public sealed record ListRolesQuery : IQuery<IReadOnlyList<RoleDto>>;
@@ -0,0 +1,13 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Admin.Roles.ListRoles;
public sealed class ListRolesQueryHandler(IRoleService roleService)
: IQueryHandler<ListRolesQuery, IReadOnlyList<RoleDto>>
{
public Task<IReadOnlyList<RoleDto>> Handle(
ListRolesQuery query,
CancellationToken cancellationToken
) => roleService.ListRolesAsync(cancellationToken);
}
@@ -0,0 +1,28 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles;
public static class RoleErrors
{
public static readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена.");
public static readonly Error DuplicateName = Error.Conflict(
"Roles.DuplicateName",
"Роль с таким именем уже существует."
);
public static readonly Error CannotModifySystemRole = Error.Forbidden(
"Roles.CannotModifySystemRole",
"Системную роль нельзя переименовать или удалить."
);
public static readonly Error RoleInUse = Error.Conflict(
"Roles.RoleInUse",
"Роль назначена пользователям — сначала смените им роль."
);
public static readonly Error CannotRemoveLastAdmin = Error.Conflict(
"Roles.CannotRemoveLastAdmin",
"Нельзя снять роль admin с последнего администратора."
);
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.UpdateRole;
public sealed record UpdateRoleCommand(Guid Id, string Name) : ICommand<Result<RoleDto>>;
@@ -0,0 +1,14 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.UpdateRole;
public sealed class UpdateRoleCommandHandler(IRoleService roleService)
: ICommandHandler<UpdateRoleCommand, Result<RoleDto>>
{
public Task<Result<RoleDto>> Handle(
UpdateRoleCommand command,
CancellationToken cancellationToken
) => roleService.UpdateRoleAsync(command.Id, command.Name, cancellationToken);
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Roles.UpdateRole;
public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCommand>
{
public UpdateRoleCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.BlockUser;
public sealed record BlockUserCommand(Guid UserId) : ICommand<Result>;
@@ -0,0 +1,19 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.BlockUser;
public sealed class BlockUserCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<BlockUserCommand, Result>
{
public Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId == command.UserId)
return Task.FromResult(Result.Failure(UserErrors.CannotBlockSelf));
return identityService.BlockUserAsync(command.UserId, cancellationToken);
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.CreateUser;
/// <summary>Создание пользователя администратором (в обход открытой регистрации), с выбором роли.</summary>
public sealed record CreateUserCommand(string UserName, string Password, Guid RoleId)
: ICommand<Result<Guid>>;
@@ -0,0 +1,44 @@
using LiteCqrs;
using TeleWave.Application.Admin.Roles;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.CreateUser;
public sealed class CreateUserCommandHandler(
IIdentityService identityService,
IRoleService roleService
) : ICommandHandler<CreateUserCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateUserCommand command,
CancellationToken cancellationToken
)
{
// Роль проверяем до создания, чтобы не оставить пользователя с ролью по умолчанию при опечатке.
var roles = await roleService.ListRolesAsync(cancellationToken);
if (roles.All(r => r.Id != command.RoleId))
return Result.Failure<Guid>(RoleErrors.NotFound);
var createResult = await identityService.CreateUserAsync(
command.UserName,
command.Password,
cancellationToken
);
if (!createResult.IsSuccess)
return Result.Failure<Guid>(createResult.Error);
var userId = createResult.Value;
// CreateUserAsync назначает роль по умолчанию — выставляем выбранную админом.
var roleResult = await roleService.ChangeUserRoleAsync(
userId,
command.RoleId,
cancellationToken
);
if (!roleResult.IsSuccess)
return Result.Failure<Guid>(roleResult.Error);
return Result.Success(userId);
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Users.CreateUser;
public sealed class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.UserName).NotEmpty().MinimumLength(3).MaximumLength(64);
RuleFor(x => x.Password).NotEmpty().MinimumLength(8);
RuleFor(x => x.RoleId).NotEmpty();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.DeleteUser;
public sealed record DeleteUserCommand(Guid UserId) : ICommand<Result>;
@@ -0,0 +1,19 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.DeleteUser;
public sealed class DeleteUserCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<DeleteUserCommand, Result>
{
public Task<Result> Handle(DeleteUserCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId == command.UserId)
return Task.FromResult(Result.Failure(UserErrors.CannotDeleteSelf));
return identityService.DeleteUserAsync(command.UserId, cancellationToken);
}
}
@@ -0,0 +1,15 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ListUsers;
public sealed record ListUsersQuery(
int Page,
int PageSize,
string? Search,
Guid? RoleId,
bool? IsBlocked,
string? Sort = null,
bool Desc = false
) : IQuery<PagedList<UserSummaryDto>>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ListUsers;
public sealed class ListUsersQueryHandler(IIdentityService identityService)
: IQueryHandler<ListUsersQuery, PagedList<UserSummaryDto>>
{
public Task<PagedList<UserSummaryDto>> Handle(
ListUsersQuery query,
CancellationToken cancellationToken
) =>
identityService.ListUsersAsync(
new UserListFilter(
query.Page,
query.PageSize,
query.Search,
query.RoleId,
query.IsBlocked,
query.Sort,
query.Desc
),
cancellationToken
);
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ResetPassword;
/// <summary>Сброс пароля пользователя администратором (без текущего пароля).</summary>
public sealed record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand<Result>;
@@ -0,0 +1,14 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ResetPassword;
public sealed class ResetUserPasswordCommandHandler(IIdentityService identityService)
: ICommandHandler<ResetUserPasswordCommand, Result>
{
public Task<Result> Handle(
ResetUserPasswordCommand command,
CancellationToken cancellationToken
) => identityService.ResetPasswordAsync(command.UserId, command.NewPassword, cancellationToken);
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Users.ResetPassword;
public sealed class ResetUserPasswordCommandValidator : AbstractValidator<ResetUserPasswordCommand>
{
public ResetUserPasswordCommandValidator()
{
// Длина — здесь; сложность (цифра/заглавная) проверяют валидаторы ASP.NET Identity при сбросе.
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.UnblockUser;
public sealed record UnblockUserCommand(Guid UserId) : ICommand<Result>;
@@ -0,0 +1,12 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.UnblockUser;
public sealed class UnblockUserCommandHandler(IIdentityService identityService)
: ICommandHandler<UnblockUserCommand, Result>
{
public Task<Result> Handle(UnblockUserCommand command, CancellationToken cancellationToken) =>
identityService.UnblockUserAsync(command.UserId, cancellationToken);
}
@@ -0,0 +1,21 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users;
public static class UserErrors
{
public static readonly Error NotFound = Error.NotFound(
"Users.NotFound",
"Пользователь не найден."
);
public static readonly Error CannotDeleteSelf = Error.Forbidden(
"Users.CannotDeleteSelf",
"Нельзя удалить собственный аккаунт через админку."
);
public static readonly Error CannotBlockSelf = Error.Forbidden(
"Users.CannotBlockSelf",
"Нельзя заблокировать собственный аккаунт."
);
}
@@ -0,0 +1,36 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth;
public static class AuthErrors
{
public static readonly Error InvalidCredentials = Error.Unauthorized(
"Auth.InvalidCredentials",
"Неверное имя пользователя или пароль."
);
public static readonly Error Unauthorized = Error.Unauthorized(
"Auth.Unauthorized",
"Требуется вход в систему."
);
public static readonly Error InvalidRefreshToken = Error.Unauthorized(
"Auth.InvalidRefreshToken",
"Сессия истекла, войдите заново."
);
public static readonly Error Blocked = Error.Forbidden(
"Auth.Blocked",
"Аккаунт заблокирован администратором."
);
public static readonly Error UserNameTaken = Error.Conflict(
"Auth.UserNameTaken",
"Это имя пользователя уже занято."
);
public static readonly Error RegistrationDisabled = Error.Forbidden(
"Auth.RegistrationDisabled",
"Регистрация на сайте отключена администратором."
);
}
@@ -0,0 +1,11 @@
namespace TeleWave.Application.Auth;
public sealed record CurrentUserDto(Guid Id, string UserName, string Role);
public sealed record AuthResult(
string AccessToken,
DateTimeOffset AccessTokenExpiresAt,
string RefreshToken,
DateTimeOffset RefreshTokenExpiresAt,
CurrentUserDto User
);
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangePassword;
public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword)
: ICommand<Result>;
@@ -0,0 +1,27 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangePassword;
public sealed class ChangePasswordCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<ChangePasswordCommand, Result>
{
public async Task<Result> Handle(
ChangePasswordCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
return await identityService.ChangePasswordAsync(
userId,
command.CurrentPassword,
command.NewPassword,
cancellationToken
);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Auth.ChangePassword;
public sealed class ChangePasswordCommandValidator : AbstractValidator<ChangePasswordCommand>
{
public ChangePasswordCommandValidator()
{
RuleFor(x => x.CurrentPassword).NotEmpty();
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangeUserName;
public sealed record ChangeUserNameCommand(string NewUserName) : ICommand<Result>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangeUserName;
public sealed class ChangeUserNameCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<ChangeUserNameCommand, Result>
{
public async Task<Result> Handle(
ChangeUserNameCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
return await identityService.ChangeUserNameAsync(
userId,
command.NewUserName,
cancellationToken
);
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace TeleWave.Application.Auth.ChangeUserName;
public sealed class ChangeUserNameCommandValidator : AbstractValidator<ChangeUserNameCommand>
{
public ChangeUserNameCommandValidator()
{
RuleFor(x => x.NewUserName).NotEmpty().MinimumLength(3).MaximumLength(64);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.DeleteMyAccount;
public sealed record DeleteMyAccountCommand : ICommand<Result>;
@@ -0,0 +1,22 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.DeleteMyAccount;
public sealed class DeleteMyAccountCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<DeleteMyAccountCommand, Result>
{
public async Task<Result> Handle(
DeleteMyAccountCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
return await identityService.DeleteUserAsync(userId, cancellationToken);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Login;
public sealed record LoginCommand(string UserName, string Password) : ICommand<Result<AuthResult>>;
@@ -0,0 +1,50 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Login;
public sealed class LoginCommandHandler(
IIdentityService identityService,
IJwtTokenService jwtTokenService,
IRefreshTokenService refreshTokenService
) : ICommandHandler<LoginCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(
LoginCommand command,
CancellationToken cancellationToken
)
{
var credentialsResult = await identityService.ValidateCredentialsAsync(
command.UserName,
command.Password,
cancellationToken
);
if (!credentialsResult.IsSuccess)
return Result.Failure<AuthResult>(credentialsResult.Error);
var user = credentialsResult.Value;
var profile = await identityService.GetProfileAsync(user.Id, cancellationToken);
if (profile is null)
return Result.Failure<AuthResult>(AuthErrors.InvalidCredentials);
if (profile.IsBlocked)
return Result.Failure<AuthResult>(AuthErrors.Blocked);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
);
var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
return Result.Success(
new AuthResult(
accessToken,
accessExpiresAt,
refreshToken.RawToken,
refreshToken.ExpiresAt,
dto
)
);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Auth.Login;
public sealed class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator()
{
RuleFor(x => x.UserName).NotEmpty();
RuleFor(x => x.Password).NotEmpty();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Logout;
public sealed record LogoutCommand(string RawToken) : ICommand<Result>;
@@ -0,0 +1,15 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Logout;
public sealed class LogoutCommandHandler(IRefreshTokenService refreshTokenService)
: ICommandHandler<LogoutCommand, Result>
{
public async Task<Result> Handle(LogoutCommand command, CancellationToken cancellationToken)
{
await refreshTokenService.RevokeAsync(command.RawToken, cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Me;
public sealed record GetCurrentUserQuery : IQuery<Result<CurrentUserDto>>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Me;
public sealed class GetCurrentUserQueryHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : IQueryHandler<GetCurrentUserQuery, Result<CurrentUserDto>>
{
public async Task<Result<CurrentUserDto>> Handle(
GetCurrentUserQuery query,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role));
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Refresh;
public sealed record RefreshCommand(string RawToken) : ICommand<Result<AuthResult>>;
@@ -0,0 +1,44 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Refresh;
public sealed class RefreshCommandHandler(
IRefreshTokenService refreshTokenService,
IIdentityService identityService,
IJwtTokenService jwtTokenService
) : ICommandHandler<RefreshCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(
RefreshCommand command,
CancellationToken cancellationToken
)
{
var rotated = await refreshTokenService.RotateAsync(command.RawToken, cancellationToken);
if (!rotated.IsSuccess)
return Result.Failure<AuthResult>(rotated.Error);
var profile = await identityService.GetProfileAsync(
rotated.Value.UserId,
cancellationToken
);
if (profile is null || profile.IsBlocked)
return Result.Failure<AuthResult>(AuthErrors.Unauthorized);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
return Result.Success(
new AuthResult(
accessToken,
accessExpiresAt,
rotated.Value.RawToken,
rotated.Value.ExpiresAt,
dto
)
);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Register;
public sealed record RegisterCommand(string UserName, string Password)
: ICommand<Result<AuthResult>>;
@@ -0,0 +1,51 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Register;
public sealed class RegisterCommandHandler(
IIdentityService identityService,
IJwtTokenService jwtTokenService,
IRefreshTokenService refreshTokenService,
ISiteSettings siteSettings
) : ICommandHandler<RegisterCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(
RegisterCommand command,
CancellationToken cancellationToken
)
{
// Открытая регистрация должна быть явно включена админом; иначе учётки заводит только он.
if (!await siteSettings.IsRegistrationEnabledAsync(cancellationToken))
return Result.Failure<AuthResult>(AuthErrors.RegistrationDisabled);
var createResult = await identityService.CreateUserAsync(
command.UserName,
command.Password,
cancellationToken
);
if (!createResult.IsSuccess)
return Result.Failure<AuthResult>(createResult.Error);
var profile = await identityService.GetProfileAsync(createResult.Value, cancellationToken);
if (profile is null)
return Result.Failure<AuthResult>(AuthErrors.Unauthorized);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
);
var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
return Result.Success(
new AuthResult(
accessToken,
accessExpiresAt,
refreshToken.RawToken,
refreshToken.ExpiresAt,
dto
)
);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Auth.Register;
public sealed class RegisterCommandValidator : AbstractValidator<RegisterCommand>
{
public RegisterCommandValidator()
{
RuleFor(x => x.UserName).NotEmpty().MinimumLength(3).MaximumLength(64);
RuleFor(x => x.Password).NotEmpty().MinimumLength(8);
}
}
@@ -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
);

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