From 4d52e115b59e8003a259b9fd03f1cfe075ef4782 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Mon, 8 Feb 2021 21:13:53 -0600 Subject: [PATCH] Initial commit --- .config/dotnet-tools.json | 18 + .dockerignore | 7 + .editorconfig | 81 + .gitignore | 42 + Dockerfile | 23 + .../Channels/ChannelViewModel.cs | 12 + .../Channels/Commands/CreateChannel.cs | 15 + .../Channels/Commands/CreateChannelHandler.cs | 58 + .../Channels/Commands/DeleteChannel.cs | 9 + .../Channels/Commands/DeleteChannelHandler.cs | 28 + .../Channels/Commands/UpdateChannel.cs | 16 + .../Channels/Commands/UpdateChannelHandler.cs | 60 + ErsatzTV.Application/Channels/Mapper.cs | 10 + .../Channels/Queries/GetAllChannels.cs | 7 + .../Channels/Queries/GetAllChannelsHandler.cs | 21 + .../Channels/Queries/GetChannelById.cs | 7 + .../Channels/Queries/GetChannelByIdHandler.cs | 20 + .../Channels/Queries/GetChannelGuide.cs | 7 + .../Queries/GetChannelGuideHandler.cs | 20 + .../Channels/Queries/GetChannelLineup.cs | 8 + .../Queries/GetChannelLineupHandler.cs | 22 + .../Channels/Queries/GetChannelPlaylist.cs | 7 + .../Queries/GetChannelPlaylistHandler.cs | 21 + .../ErsatzTV.Application.csproj | 18 + .../Commands/CreateFFmpegProfile.cs | 25 + .../Commands/CreateFFmpegProfileHandler.cs | 72 + .../Commands/DeleteFFmpegProfile.cs | 9 + .../Commands/DeleteFFmpegProfileHandler.cs | 32 + .../Commands/NewFFmpegProfile.cs | 10 + .../Commands/NewFFmpegProfileHandler.cs | 40 + .../Commands/UpdateFFmpegProfile.cs | 26 + .../Commands/UpdateFFmpegProfileHandler.cs | 78 + .../Commands/UpdateFFmpegSettings.cs | 6 + .../Commands/UpdateFFmpegSettingsHandler.cs | 70 + .../FFmpegProfiles/FFmpegProfileViewModel.cs | 24 + .../FFmpegProfiles/FFmpegSettingsViewModel.cs | 9 + ErsatzTV.Application/FFmpegProfiles/Mapper.cs | 32 + .../Queries/GetAllFFmpegProfiles.cs | 7 + .../Queries/GetAllFFmpegProfilesHandler.cs | 23 + .../Queries/GetFFmpegProfileById.cs | 7 + .../Queries/GetFFmpegProfileByIdHandler.cs | 23 + .../Queries/GetFFmpegSettings.cs | 6 + .../Queries/GetFFmpegSettingsHandler.cs | 34 + .../IBackgroundServiceRequest.cs | 6 + .../IPlexBackgroundServiceRequest.cs | 6 + .../Images/Commands/SaveImageToDisk.cs | 9 + .../Images/Commands/SaveImageToDiskHandler.cs | 43 + ErsatzTV.Application/Images/ImageViewModel.cs | 5 + .../Images/Queries/GetImageContents.cs | 8 + .../Images/Queries/GetImageContentsHandler.cs | 44 + .../Commands/CreateSimpleMediaCollection.cs | 9 + .../CreateSimpleMediaCollectionHandler.cs | 49 + .../Commands/DeleteSimpleMediaCollection.cs | 9 + .../DeleteSimpleMediaCollectionHandler.cs | 34 + .../ReplaceSimpleMediaCollectionItems.cs | 11 + ...eplaceSimpleMediaCollectionItemsHandler.cs | 65 + .../Commands/UpdateChannelHandler.cs | 47 + .../Commands/UpdateSimpleMediaCollection.cs | 8 + .../MediaCollections/Mapper.cs | 19 + .../MediaCollectionSummaryViewModel.cs | 4 + .../MediaCollectionViewModel.cs | 4 + .../Queries/GetAllMediaCollections.cs | 7 + .../Queries/GetAllMediaCollectionsHandler.cs | 24 + .../Queries/GetAllSimpleMediaCollections.cs | 7 + .../GetAllSimpleMediaCollectionsHandler.cs | 25 + .../Queries/GetMediaCollectionSummaries.cs | 7 + .../GetMediaCollectionSummariesHandler.cs | 27 + .../Queries/GetSimpleMediaCollectionById.cs | 7 + .../GetSimpleMediaCollectionByIdHandler.cs | 25 + .../Queries/GetSimpleMediaCollectionItems.cs | 9 + .../GetSimpleMediaCollectionItemsHandler.cs | 26 + .../MediaItems/AggregateMediaItemViewModel.cs | 4 + .../MediaItems/Commands/CreateMediaItem.cs | 8 + .../Commands/CreateMediaItemHandler.cs | 96 + .../MediaItems/Commands/DeleteMediaItem.cs | 9 + .../Commands/DeleteMediaItemHandler.cs | 31 + .../MediaItems/Commands/RefreshMediaItem.cs | 8 + .../Commands/RefreshMediaItemCollections.cs | 9 + .../RefreshMediaItemCollectionsHandler.cs | 46 + .../Commands/RefreshMediaItemMetadata.cs | 9 + .../RefreshMediaItemMetadataHandler.cs | 52 + .../Commands/RefreshMediaItemStatistics.cs | 9 + .../RefreshMediaItemStatisticsHandler.cs | 65 + ErsatzTV.Application/MediaItems/Mapper.cs | 13 + .../MediaItems/MediaItemViewModel.cs | 4 + .../Queries/GetAggregateMediaItems.cs | 9 + .../Queries/GetAggregateMediaItemsHandler.cs | 43 + .../MediaItems/Queries/GetAllMediaItems.cs | 7 + .../Queries/GetAllMediaItemsHandler.cs | 23 + .../MediaItems/Queries/GetMediaItemById.cs | 7 + .../Queries/GetMediaItemByIdHandler.cs | 23 + .../Commands/CreateLocalMediaSource.cs | 10 + .../Commands/CreateLocalMediaSourceHandler.cs | 78 + .../Commands/DeleteLocalMediaSource.cs | 9 + .../Commands/DeleteLocalMediaSourceHandler.cs | 50 + .../Commands/ScanLocalMediaSource.cs | 9 + .../Commands/ScanLocalMediaSourceHandler.cs | 56 + .../MediaSources/Commands/StartPlexPinFlow.cs | 8 + .../Commands/StartPlexPinFlowHandler.cs | 38 + .../Commands/SynchronizePlexLibraries.cs | 7 + .../SynchronizePlexLibrariesHandler.cs | 106 ++ .../Commands/SynchronizePlexMediaSources.cs | 11 + .../SynchronizePlexMediaSourcesHandler.cs | 84 + .../Commands/TryCompletePlexPinFlow.cs | 10 + .../Commands/TryCompletePlexPinFlowHandler.cs | 45 + .../MediaSources/LocalMediaSourceViewModel.cs | 7 + ErsatzTV.Application/MediaSources/Mapper.cs | 22 + .../MediaSources/MediaSourceViewModel.cs | 6 + .../MediaSources/PlexMediaSourceViewModel.cs | 9 + .../Queries/CountMediaItemsById.cs | 6 + .../Queries/CountMediaItemsByIdHandler.cs | 18 + .../Queries/GetAllMediaSources.cs | 7 + .../Queries/GetAllMediaSourcesHandler.cs | 23 + .../Queries/GetAllPlexMediaSources.cs | 7 + .../Queries/GetAllPlexMediaSourcesHandler.cs | 24 + .../Queries/GetMediaSourceById.cs | 7 + .../Queries/GetMediaSourceByIdHandler.cs | 23 + .../Playouts/Commands/BuildPlayout.cs | 8 + .../Playouts/Commands/BuildPlayoutHandler.cs | 42 + .../Playouts/Commands/CreatePlayout.cs | 12 + .../Playouts/Commands/CreatePlayoutHandler.cs | 73 + .../Playouts/Commands/DeletePlayout.cs | 9 + .../Playouts/Commands/DeletePlayoutHandler.cs | 32 + .../Playouts/Commands/UpdatePlayout.cs | 13 + .../Playouts/Commands/UpdatePlayoutHandler.cs | 75 + ErsatzTV.Application/Playouts/Mapper.cs | 36 + .../Playouts/PlayoutChannelViewModel.cs | 4 + .../Playouts/PlayoutItemViewModel.cs | 6 + .../PlayoutProgramScheduleViewModel.cs | 4 + .../Playouts/PlayoutViewModel.cs | 10 + .../Playouts/Queries/GetAllPlayouts.cs | 7 + .../Playouts/Queries/GetAllPlayoutsHandler.cs | 24 + .../Playouts/Queries/GetPlayoutById.cs | 7 + .../Playouts/Queries/GetPlayoutByIdHandler.cs | 24 + .../Playouts/Queries/GetPlayoutItemsById.cs | 7 + .../Queries/GetPlayoutItemsByIdHandler.cs | 25 + .../Commands/AddProgramScheduleItem.cs | 18 + .../Commands/AddProgramScheduleItemHandler.cs | 61 + .../Commands/CreateProgramSchedule.cs | 10 + .../Commands/CreateProgramScheduleHandler.cs | 43 + .../Commands/DeleteProgramSchedule.cs | 9 + .../Commands/DeleteProgramScheduleHandler.cs | 32 + .../Commands/IProgramScheduleItemRequest.cs | 15 + .../ProgramScheduleItemCommandBase.cs | 94 + .../Commands/ReplaceProgramScheduleItems.cs | 23 + .../ReplaceProgramScheduleItemsHandler.cs | 66 + .../Commands/UpdateProgramSchedule.cs | 13 + .../Commands/UpdateProgramScheduleHandler.cs | 72 + .../ProgramSchedules/Mapper.cs | 46 + .../ProgramScheduleItemDurationViewModel.cs | 31 + .../ProgramScheduleItemFloodViewModel.cs | 24 + .../ProgramScheduleItemMultipleViewModel.cs | 26 + .../ProgramScheduleItemOneViewModel.cs | 24 + .../ProgramScheduleItemViewModel.cs | 14 + .../ProgramScheduleViewModel.cs | 6 + .../Queries/GetAllProgramSchedules.cs | 7 + .../Queries/GetAllProgramSchedulesHandler.cs | 23 + .../Queries/GetProgramScheduleById.cs | 7 + .../Queries/GetProgramScheduleByIdHandler.cs | 24 + .../Queries/GetProgramScheduleItems.cs | 8 + .../Queries/GetProgramScheduleItemsHandler.cs | 25 + .../FFmpegProfileResolutionViewModel.cs | 4 + ErsatzTV.Application/Resolutions/Mapper.cs | 10 + .../Resolutions/Queries/GetAllResolutions.cs | 7 + .../Queries/GetAllResolutionsHandler.cs | 22 + .../Streaming/Queries/FFmpegProcessHandler.cs | 49 + .../Streaming/Queries/FFmpegProcessRequest.cs | 9 + .../GetConcatPlaylistByChannelNumber.cs | 10 + ...GetConcatPlaylistByChannelNumberHandler.cs | 35 + .../GetConcatProcessByChannelNumber.cs | 14 + .../GetConcatProcessByChannelNumberHandler.cs | 34 + .../Queries/GetHlsPlaylistByChannelNumber.cs | 9 + .../GetHlsPlaylistByChannelNumberHandler.cs | 63 + .../GetPlayoutItemProcessByChannelNumber.cs | 9 + ...layoutItemProcessByChannelNumberHandler.cs | 50 + .../Validators/GetMemberName.cs | 20 + .../Validators/NumericValidation.cs | 19 + .../Validators/StringValidation.cs | 24 + .../Commands/ChannelCommand.cs | 106 ++ .../Commands/ConfigCommand.cs | 31 + .../Commands/FFmpegProfileCommand.cs | 151 ++ .../MediaCollectionClearCommand.cs | 86 + .../MediaCollectionCreateCommand.cs | 72 + .../MediaCollectionMediaItemsCommand.cs | 143 ++ .../Commands/MediaItemCommandBase.cs | 42 + .../Commands/MediaItemsCommand.cs | 99 ++ .../Commands/PlayoutCommand.cs | 107 ++ .../Schedules/ScheduleAddItemCommand.cs | 140 ++ .../Schedules/ScheduleCreateCommand.cs | 86 + ErsatzTV.CommandLine/Config.cs | 7 + ErsatzTV.CommandLine/DesiredResolution.cs | 10 + .../ErsatzTV.CommandLine.csproj | 35 + ErsatzTV.CommandLine/Program.cs | 75 + .../ErsatzTV.Core.Tests.csproj | 23 + .../FFmpegPlaybackSettingsServiceTests.cs | 758 ++++++++ .../Fakes/FakeMediaCollectionRepository.cs | 53 + .../Scheduling/ChronologicalContentTests.cs | 95 + .../Scheduling/PlayoutBuilderTests.cs | 547 ++++++ .../Scheduling/RandomizedContentTests.cs | 111 ++ .../Scheduling/ShuffledContentTests.cs | 108 ++ .../AggregateModels/GenericIntegerId.cs | 4 + .../AggregateModels/MediaCollectionSummary.cs | 4 + ErsatzTV.Core/BaseError.cs | 18 + ErsatzTV.Core/Domain/Channel.cs | 22 + ErsatzTV.Core/Domain/ConfigElement.cs | 9 + ErsatzTV.Core/Domain/ConfigElementKey.cs | 14 + ErsatzTV.Core/Domain/FFmpegProfile.cs | 48 + ErsatzTV.Core/Domain/LocalMediaSource.cs | 10 + ErsatzTV.Core/Domain/MediaCollection.cs | 10 + .../Domain/MediaCollectionEnumeratorState.cs | 8 + ErsatzTV.Core/Domain/MediaItem.cs | 16 + ErsatzTV.Core/Domain/MediaMetadata.cs | 25 + ErsatzTV.Core/Domain/MediaSource.cs | 9 + ErsatzTV.Core/Domain/MediaSourceType.cs | 10 + ErsatzTV.Core/Domain/MediaType.cs | 9 + ErsatzTV.Core/Domain/PlaybackOrder.cs | 9 + ErsatzTV.Core/Domain/Playout.cs | 17 + ErsatzTV.Core/Domain/PlayoutAnchor.cs | 13 + ErsatzTV.Core/Domain/PlayoutItem.cs | 15 + ErsatzTV.Core/Domain/PlayoutMode.cs | 25 + .../Domain/PlayoutProgramScheduleAnchor.cs | 13 + ErsatzTV.Core/Domain/PlexMediaSource.cs | 16 + .../Domain/PlexMediaSourceConnection.cs | 9 + .../Domain/PlexMediaSourceLibrary.cs | 10 + ErsatzTV.Core/Domain/ProgramSchedule.cs | 13 + ErsatzTV.Core/Domain/ProgramScheduleItem.cs | 16 + .../Domain/ProgramScheduleItemDuration.cs | 10 + .../Domain/ProgramScheduleItemFlood.cs | 6 + .../Domain/ProgramScheduleItemMultiple.cs | 7 + .../Domain/ProgramScheduleItemOne.cs | 6 + .../Domain/ProgramSchedulePlayoutType.cs | 9 + ErsatzTV.Core/Domain/Resolution.cs | 12 + ErsatzTV.Core/Domain/ResolutionKey.cs | 11 + ErsatzTV.Core/Domain/SimpleMediaCollection.cs | 9 + ErsatzTV.Core/Domain/SourceMode.cs | 9 + ErsatzTV.Core/Domain/StartType.cs | 8 + ErsatzTV.Core/Domain/StreamingMode.cs | 8 + .../Domain/TelevisionMediaCollection.cs | 8 + ErsatzTV.Core/Domain/VideoScanType.cs | 9 + ErsatzTV.Core/ErsatzTV.Core.csproj | 16 + ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs | 10 + ErsatzTV.Core/FFmpeg/DisplaySize.cs | 6 + ErsatzTV.Core/FFmpeg/DisplaySizeExtensions.cs | 13 + ErsatzTV.Core/FFmpeg/FFmpegLocator.cs | 74 + .../FFmpeg/FFmpegPlaybackSettings.cs | 28 + .../FFmpegPlaybackSettingsCalculator.cs | 223 +++ ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs | 382 ++++ ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs | 132 ++ ErsatzTV.Core/FileSystemLayout.cs | 18 + ErsatzTV.Core/Hdhr/DeviceXml.cs | 23 + ErsatzTV.Core/Hdhr/Discover.cs | 29 + ErsatzTV.Core/Hdhr/LineupItem.cs | 24 + ErsatzTV.Core/Hdhr/LineupStatus.cs | 12 + .../Interfaces/FFmpeg/IDisplaySize.cs | 8 + .../Interfaces/FFmpeg/IFFmpegLocator.cs | 11 + .../Interfaces/Metadata/ILocalMediaScanner.cs | 11 + .../Metadata/ILocalMetadataProvider.cs | 10 + .../Metadata/ILocalStatisticsProvider.cs | 10 + .../Metadata/ISmartCollectionBuilder.cs | 10 + .../Interfaces/Plex/IPlexSecretStore.cs | 17 + .../Interfaces/Plex/IPlexServerApiClient.cs | 15 + .../Interfaces/Plex/IPlexTvApiClient.cs | 15 + .../Repositories/IChannelRepository.cs | 18 + .../Repositories/IConfigElementRepository.cs | 15 + .../Repositories/IFFmpegProfileRepository.cs | 16 + .../IMediaCollectionRepository.cs | 27 + .../Repositories/IMediaItemRepository.cs | 18 + .../Repositories/IMediaSourceRepository.cs | 20 + .../Repositories/IPlayoutRepository.cs | 21 + .../IProgramScheduleRepository.cs | 18 + .../Repositories/IResolutionRepository.cs | 13 + .../Scheduling/IMediaCollectionEnumerator.cs | 13 + .../Interfaces/Scheduling/IPlayoutBuilder.cs | 17 + ErsatzTV.Core/Iptv/ChannelGuide.cs | 86 + ErsatzTV.Core/Iptv/ChannelPlaylist.cs | 52 + ErsatzTV.Core/LanguageExtensions.cs | 16 + ErsatzTV.Core/Metadata/LocalMediaScanner.cs | 140 ++ .../Metadata/LocalMetadataProvider.cs | 179 ++ .../Metadata/LocalStatisticsProvider.cs | 147 ++ .../Metadata/SmartCollectionBuilder.cs | 61 + ErsatzTV.Core/Plex/PlexAuthPin.cs | 16 + ErsatzTV.Core/Plex/PlexServerAuthToken.cs | 4 + ErsatzTV.Core/Plex/PlexUserAuthToken.cs | 4 + .../ChronologicalMediaCollectionEnumerator.cs | 41 + ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 336 ++++ .../RandomizedMediaCollectionEnumerator.cs | 76 + .../ShuffledMediaCollectionEnumerator.cs | 101 ++ .../Configurations/ChannelConfiguration.cs | 20 + .../ConfigElementConfiguration.cs | 13 + .../GenericIntegerIdConfiguration.cs | 12 + .../LocalMediaSourceConfiguration.cs | 12 + .../MediaCollectionConfiguration.cs | 12 + .../MediaCollectionSummaryConfiguration.cs | 12 + .../Configurations/MediaItemConfiguration.cs | 12 + .../MediaSourceConfiguration.cs | 12 + .../Configurations/PlayoutConfiguration.cs | 24 + ...ayoutProgramScheduleAnchorConfiguration.cs | 16 + .../PlexMediaSourceConfiguration.cs | 11 + .../PlexMediaSourceConnectionConfiguration.cs | 12 + .../PlexMediaSourceLibraryConfiguration.cs | 12 + .../ProgramScheduleConfiguration.cs | 24 + .../ProgramScheduleItemConfiguration.cs | 12 + ...rogramScheduleItemDurationConfiguration.cs | 12 + .../ProgramScheduleItemFloodConfiguration.cs | 12 + ...rogramScheduleItemMultipleConfiguration.cs | 12 + .../ProgramScheduleItemOneConfiguration.cs | 12 + .../SimpleMediaCollectionConfiguration.cs | 17 + .../TelevisionMediaCollectionConfiguration.cs | 17 + ErsatzTV.Infrastructure/Data/DbInitializer.cs | 70 + .../Data/Repositories/ChannelRepository.cs | 56 + .../Repositories/ConfigElementRepository.cs | 41 + .../Repositories/FFmpegProfileRepository.cs | 46 + .../Repositories/MediaCollectionRepository.cs | 142 ++ .../Data/Repositories/MediaItemRepository.cs | 53 + .../Repositories/MediaSourceRepository.cs | 76 + .../Data/Repositories/PlayoutRepository.cs | 99 ++ .../Repositories/ProgramScheduleRepository.cs | 65 + .../Data/Repositories/ResolutionRepository.cs | 23 + ErsatzTV.Infrastructure/Data/TvContext.cs | 45 + .../ErsatzTV.Infrastructure.csproj | 17 + ErsatzTV.Infrastructure/HttpLoggingHandler.cs | 115 ++ .../Plex/IPlexServerApi.cs | 15 + ErsatzTV.Infrastructure/Plex/IPlexTvApi.cs | 44 + .../Plex/Models/PlexLibraryResponse.cs | 10 + .../Plex/Models/PlexMediaContainerResponse.cs | 14 + .../Plex/Models/PlexPinResponse.cs | 8 + .../Plex/Models/PlexResource.cs | 17 + .../Plex/Models/PlexResourceConnection.cs | 11 + .../Plex/Models/PlexTokenResponse.cs | 7 + .../Plex/Models/PlexUserResponse.cs | 8 + .../Plex/PlexSecretStore.cs | 80 + ErsatzTV.Infrastructure/Plex/PlexSecrets.cs | 11 + .../Plex/PlexServerApiClient.cs | 58 + .../Plex/PlexTvApiClient.cs | 116 ++ ErsatzTV.sln | 54 + ErsatzTV.sln.DotSettings | 25 + ErsatzTV/App.razor | 10 + .../Controllers/Api/ChannelsController.cs | 57 + .../Api/FFmpegProfileController.cs | 57 + .../Api/MediaCollectionsController.cs | 57 + .../Controllers/Api/MediaItemsController.cs | 49 + .../Controllers/Api/MediaSourcesController.cs | 31 + ErsatzTV/Controllers/Api/PlayoutController.cs | 57 + .../Api/ProgramScheduleController.cs | 87 + ErsatzTV/Controllers/HdhrController.cs | 34 + ErsatzTV/Controllers/InternalController.cs | 52 + ErsatzTV/Controllers/IptvController.cs | 72 + ErsatzTV/ErsatzTV.csproj | 34 + ErsatzTV/Extensions/EitherToActionResult.cs | 28 + ErsatzTV/Extensions/HostExtensions.cs | 45 + ErsatzTV/Extensions/ListToActionResult.cs | 13 + ErsatzTV/Extensions/OptionToActionResult.cs | 16 + .../Extensions/ValidationToActionResult.cs | 30 + .../Formatters/ChannelGuideOutputFormatter.cs | 27 + .../ChannelPlaylistOutputFormatter.cs | 27 + .../ConcatPlaylistOutputFormatter.cs | 27 + .../Formatters/DeviceXmlOutputFormatter.cs | 27 + .../Formatters/HdhrJsonOutputFormatter.cs | 43 + ErsatzTV/Models/UI/MediaItemExtensions.cs | 25 + ErsatzTV/Pages/ChannelEditor.razor | 148 ++ ErsatzTV/Pages/Channels.razor | 91 + ErsatzTV/Pages/FFmpeg.razor | 128 ++ ErsatzTV/Pages/FFmpegEditor.razor | 150 ++ ErsatzTV/Pages/Index.razor | 7 + ErsatzTV/Pages/LocalMediaSourceEditor.razor | 88 + ErsatzTV/Pages/MediaCollectionEditor.razor | 82 + .../Pages/MediaCollectionItemsEditor.razor | 126 ++ ErsatzTV/Pages/MediaCollections.razor | 104 ++ ErsatzTV/Pages/MediaItems.razor | 18 + ErsatzTV/Pages/MediaSources.razor | 14 + ErsatzTV/Pages/Playouts.razor | 56 + ErsatzTV/Pages/ScheduleEditor.razor | 91 + ErsatzTV/Pages/ScheduleItemsEditor.razor | 98 + ErsatzTV/Pages/Schedules.razor | 102 ++ ErsatzTV/Pages/_Host.cshtml | 37 + ErsatzTV/Program.cs | 56 + ErsatzTV/Properties/launchSettings.json | 12 + ErsatzTV/Resources/ErsatzTV.png | Bin 0 -> 1615 bytes ErsatzTV/Resources/Roboto-Regular.ttf | Bin 0 -> 171272 bytes ErsatzTV/Resources/background.png | Bin 0 -> 14272 bytes .../Serialization/CustomContractResolver.cs | 9 + .../Serialization/CustomNamingStrategy.cs | 18 + ErsatzTV/Services/FFmpegLocatorService.cs | 46 + ErsatzTV/Services/PlexService.cs | 144 ++ ErsatzTV/Services/SchedulerService.cs | 82 + ErsatzTV/Services/WorkerService.cs | 102 ++ ErsatzTV/Shared/DeleteDialog.razor | 48 + ErsatzTV/Shared/LocalMediaSources.razor | 60 + ErsatzTV/Shared/MainLayout.razor | 57 + ErsatzTV/Shared/MainLayout.razor.css | 58 + ErsatzTV/Shared/MediaItemTable.razor | 66 + ErsatzTV/Shared/PlexMediaSources.razor | 90 + ErsatzTV/Startup.cs | 184 ++ .../ChannelEditViewModelValidator.cs | 15 + .../FFmpegProfileEditViewModelValidator.cs | 28 + .../LocalMediaSourceEditViewModelValidator.cs | 15 + .../ProgramScheduleEditViewModelValidator.cs | 10 + ...leMediaCollectionEditViewModelValidator.cs | 10 + ErsatzTV/ViewModels/ChannelEditViewModel.cs | 32 + .../ViewModels/FFmpegProfileEditViewModel.cs | 97 + .../LocalMediaSourceEditViewModel.cs | 10 + .../ProgramScheduleEditViewModel.cs | 14 + .../ProgramScheduleItemEditViewModel.cs | 16 + .../ProgramScheduleItemsEditViewModel.cs | 10 + .../SimpleMediaCollectionEditViewModel.cs | 8 + ErsatzTV/_Imports.razor | 26 + ErsatzTV/appsettings.Development.json | 23 + ErsatzTV/appsettings.json | 28 + .../wwwroot/css/bootstrap/bootstrap.min.css | 7 + .../css/bootstrap/bootstrap.min.css.map | 1 + ErsatzTV/wwwroot/css/open-iconic/FONT-LICENSE | 86 + ErsatzTV/wwwroot/css/open-iconic/ICON-LICENSE | 21 + ErsatzTV/wwwroot/css/open-iconic/README.md | 114 ++ .../font/css/open-iconic-bootstrap.min.css | 1 + .../open-iconic/font/fonts/open-iconic.eot | Bin 0 -> 28196 bytes .../open-iconic/font/fonts/open-iconic.otf | Bin 0 -> 20996 bytes .../open-iconic/font/fonts/open-iconic.svg | 543 ++++++ .../open-iconic/font/fonts/open-iconic.ttf | Bin 0 -> 28028 bytes .../open-iconic/font/fonts/open-iconic.woff | Bin 0 -> 14984 bytes ErsatzTV/wwwroot/css/site.css | 1 + ErsatzTV/wwwroot/images/ersatztv-500.png | Bin 0 -> 4721 bytes LICENSE | 19 + README.md | 52 + artwork/ersatztv-logo.svg | 88 + docker-compose.yml | 8 + docs/plex-live-tv-stream.png | Bin 0 -> 255194 bytes .../.openapi-generator-ignore | 5 + .../ErsatzTV.Api.Sdk/.openapi-generator/FILES | 104 ++ .../.openapi-generator/VERSION | 1 + .../src/ErsatzTV.Api.Sdk/Api/ChannelsApi.cs | 935 ++++++++++ .../ErsatzTV.Api.Sdk/Api/FFmpegProfileApi.cs | 935 ++++++++++ .../Api/MediaCollectionsApi.cs | 931 ++++++++++ .../src/ErsatzTV.Api.Sdk/Api/MediaItemsApi.cs | 771 ++++++++ .../ErsatzTV.Api.Sdk/Api/MediaSourcesApi.cs | 445 +++++ .../src/ErsatzTV.Api.Sdk/Api/PlayoutApi.cs | 935 ++++++++++ .../Api/ProgramScheduleApi.cs | 1569 +++++++++++++++++ .../src/ErsatzTV.Api.Sdk/Client/ApiClient.cs | 835 +++++++++ .../ErsatzTV.Api.Sdk/Client/ApiException.cs | 68 + .../ErsatzTV.Api.Sdk/Client/ApiResponse.cs | 166 ++ .../ErsatzTV.Api.Sdk/Client/ClientUtils.cs | 214 +++ .../ErsatzTV.Api.Sdk/Client/Configuration.cs | 520 ++++++ .../Client/ExceptionFactory.cs | 22 + .../Client/GlobalConfiguration.cs | 67 + .../src/ErsatzTV.Api.Sdk/Client/HttpMethod.cs | 33 + .../ErsatzTV.Api.Sdk/Client/IApiAccessor.cs | 37 + .../Client/IAsynchronousClient.cs | 100 ++ .../Client/IReadableConfiguration.cs | 115 ++ .../Client/ISynchronousClient.cs | 93 + .../src/ErsatzTV.Api.Sdk/Client/Multimap.cs | 295 ++++ .../Client/OpenAPIDateConverter.cs | 29 + .../ErsatzTV.Api.Sdk/Client/RequestOptions.cs | 74 + .../Client/RetryConfiguration.cs | 21 + .../ErsatzTV.Api.Sdk/ErsatzTV.Api.Sdk.csproj | 30 + .../Model/AbstractOpenAPISchema.cs | 76 + .../Model/AddProgramScheduleItem.cs | 227 +++ .../Model/ChannelViewModel.cs | 196 ++ .../ErsatzTV.Api.Sdk/Model/CreateChannel.cs | 182 ++ .../Model/CreateFFmpegProfile.cs | 353 ++++ .../ErsatzTV.Api.Sdk/Model/CreateMediaItem.cs | 139 ++ .../ErsatzTV.Api.Sdk/Model/CreatePlayout.cs | 150 ++ .../Model/CreateProgramSchedule.cs | 138 ++ .../Model/CreateSimpleMediaCollection.cs | 125 ++ .../ErsatzTV.Api.Sdk/Model/DeleteChannel.cs | 123 ++ .../Model/DeleteFFmpegProfile.cs | 123 ++ .../ErsatzTV.Api.Sdk/Model/DeleteMediaItem.cs | 123 ++ .../ErsatzTV.Api.Sdk/Model/DeletePlayout.cs | 123 ++ .../Model/DeleteProgramSchedule.cs | 123 ++ .../Model/FFmpegProfileViewModel.cs | 369 ++++ .../Model/MediaCollectionViewModel.cs | 139 ++ .../Model/MediaItemViewModel.cs | 153 ++ .../ErsatzTV.Api.Sdk/Model/MediaSourceType.cs | 62 + .../Model/MediaSourceViewModel.cs | 152 ++ .../ErsatzTV.Api.Sdk/Model/PlaybackOrder.cs | 56 + .../Model/PlayoutChannelViewModel.cs | 139 ++ .../src/ErsatzTV.Api.Sdk/Model/PlayoutMode.cs | 62 + .../Model/PlayoutProgramScheduleViewModel.cs | 139 ++ .../Model/PlayoutViewModel.cs | 168 ++ .../ErsatzTV.Api.Sdk/Model/ProblemDetails.cs | 190 ++ .../Model/ProgramScheduleItemViewModel.cs | 195 ++ .../Model/ProgramSchedulePlayoutType.cs | 56 + .../Model/ProgramScheduleViewModel.cs | 152 ++ .../Model/ReplaceProgramScheduleItem.cs | 227 +++ .../Model/ResolutionViewModel.cs | 167 ++ .../src/ErsatzTV.Api.Sdk/Model/StartType.cs | 50 + .../ErsatzTV.Api.Sdk/Model/StreamingMode.cs | 50 + .../ErsatzTV.Api.Sdk/Model/UpdateChannel.cs | 196 ++ .../Model/UpdateFFmpegProfile.cs | 367 ++++ .../ErsatzTV.Api.Sdk/Model/UpdatePlayout.cs | 164 ++ .../Model/UpdateProgramSchedule.cs | 152 ++ scripts/cleanup-code.sh | 6 + .../.openapi-generator-ignore | 5 + scripts/generate-api-sdk/generate-api-sdk.sh | 24 + scripts/generate-api-sdk/openapitools.json | 7 + 493 files changed, 31399 insertions(+) create mode 100644 .config/dotnet-tools.json create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 ErsatzTV.Application/Channels/ChannelViewModel.cs create mode 100644 ErsatzTV.Application/Channels/Commands/CreateChannel.cs create mode 100644 ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs create mode 100644 ErsatzTV.Application/Channels/Commands/DeleteChannel.cs create mode 100644 ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs create mode 100644 ErsatzTV.Application/Channels/Commands/UpdateChannel.cs create mode 100644 ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs create mode 100644 ErsatzTV.Application/Channels/Mapper.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetAllChannels.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetChannelById.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetChannelByIdHandler.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetChannelLineup.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetChannelLineupHandler.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetChannelPlaylist.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs create mode 100644 ErsatzTV.Application/ErsatzTV.Application.csproj create mode 100644 ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfile.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Commands/NewFFmpegProfile.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Commands/NewFFmpegProfileHandler.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettings.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Mapper.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Queries/GetAllFFmpegProfiles.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Queries/GetAllFFmpegProfilesHandler.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileById.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdHandler.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettings.cs create mode 100644 ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs create mode 100644 ErsatzTV.Application/IBackgroundServiceRequest.cs create mode 100644 ErsatzTV.Application/IPlexBackgroundServiceRequest.cs create mode 100644 ErsatzTV.Application/Images/Commands/SaveImageToDisk.cs create mode 100644 ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs create mode 100644 ErsatzTV.Application/Images/ImageViewModel.cs create mode 100644 ErsatzTV.Application/Images/Queries/GetImageContents.cs create mode 100644 ErsatzTV.Application/Images/Queries/GetImageContentsHandler.cs create mode 100644 ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollection.cs create mode 100644 ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollectionHandler.cs create mode 100644 ErsatzTV.Application/MediaCollections/Commands/DeleteSimpleMediaCollection.cs create mode 100644 ErsatzTV.Application/MediaCollections/Commands/DeleteSimpleMediaCollectionHandler.cs create mode 100644 ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItems.cs create mode 100644 ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItemsHandler.cs create mode 100644 ErsatzTV.Application/MediaCollections/Commands/UpdateChannelHandler.cs create mode 100644 ErsatzTV.Application/MediaCollections/Commands/UpdateSimpleMediaCollection.cs create mode 100644 ErsatzTV.Application/MediaCollections/Mapper.cs create mode 100644 ErsatzTV.Application/MediaCollections/MediaCollectionSummaryViewModel.cs create mode 100644 ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs create mode 100644 ErsatzTV.Application/MediaCollections/Queries/GetAllMediaCollections.cs create mode 100644 ErsatzTV.Application/MediaCollections/Queries/GetAllMediaCollectionsHandler.cs create mode 100644 ErsatzTV.Application/MediaCollections/Queries/GetAllSimpleMediaCollections.cs create mode 100644 ErsatzTV.Application/MediaCollections/Queries/GetAllSimpleMediaCollectionsHandler.cs create mode 100644 ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummaries.cs create mode 100644 ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummariesHandler.cs create mode 100644 ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionById.cs create mode 100644 ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionByIdHandler.cs create mode 100644 ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionItems.cs create mode 100644 ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionItemsHandler.cs create mode 100644 ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs create mode 100644 ErsatzTV.Application/MediaItems/Commands/CreateMediaItem.cs create mode 100644 ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs create mode 100644 ErsatzTV.Application/MediaItems/Commands/DeleteMediaItem.cs create mode 100644 ErsatzTV.Application/MediaItems/Commands/DeleteMediaItemHandler.cs create mode 100644 ErsatzTV.Application/MediaItems/Commands/RefreshMediaItem.cs create mode 100644 ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollections.cs create mode 100644 ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollectionsHandler.cs create mode 100644 ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadata.cs create mode 100644 ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs create mode 100644 ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatistics.cs create mode 100644 ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatisticsHandler.cs create mode 100644 ErsatzTV.Application/MediaItems/Mapper.cs create mode 100644 ErsatzTV.Application/MediaItems/MediaItemViewModel.cs create mode 100644 ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs create mode 100644 ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs create mode 100644 ErsatzTV.Application/MediaItems/Queries/GetAllMediaItems.cs create mode 100644 ErsatzTV.Application/MediaItems/Queries/GetAllMediaItemsHandler.cs create mode 100644 ErsatzTV.Application/MediaItems/Queries/GetMediaItemById.cs create mode 100644 ErsatzTV.Application/MediaItems/Queries/GetMediaItemByIdHandler.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSource.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSource.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSourceHandler.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlow.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlowHandler.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibraries.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibrariesHandler.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/SynchronizePlexMediaSources.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/SynchronizePlexMediaSourcesHandler.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/TryCompletePlexPinFlow.cs create mode 100644 ErsatzTV.Application/MediaSources/Commands/TryCompletePlexPinFlowHandler.cs create mode 100644 ErsatzTV.Application/MediaSources/LocalMediaSourceViewModel.cs create mode 100644 ErsatzTV.Application/MediaSources/Mapper.cs create mode 100644 ErsatzTV.Application/MediaSources/MediaSourceViewModel.cs create mode 100644 ErsatzTV.Application/MediaSources/PlexMediaSourceViewModel.cs create mode 100644 ErsatzTV.Application/MediaSources/Queries/CountMediaItemsById.cs create mode 100644 ErsatzTV.Application/MediaSources/Queries/CountMediaItemsByIdHandler.cs create mode 100644 ErsatzTV.Application/MediaSources/Queries/GetAllMediaSources.cs create mode 100644 ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesHandler.cs create mode 100644 ErsatzTV.Application/MediaSources/Queries/GetAllPlexMediaSources.cs create mode 100644 ErsatzTV.Application/MediaSources/Queries/GetAllPlexMediaSourcesHandler.cs create mode 100644 ErsatzTV.Application/MediaSources/Queries/GetMediaSourceById.cs create mode 100644 ErsatzTV.Application/MediaSources/Queries/GetMediaSourceByIdHandler.cs create mode 100644 ErsatzTV.Application/Playouts/Commands/BuildPlayout.cs create mode 100644 ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs create mode 100644 ErsatzTV.Application/Playouts/Commands/CreatePlayout.cs create mode 100644 ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs create mode 100644 ErsatzTV.Application/Playouts/Commands/DeletePlayout.cs create mode 100644 ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs create mode 100644 ErsatzTV.Application/Playouts/Commands/UpdatePlayout.cs create mode 100644 ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs create mode 100644 ErsatzTV.Application/Playouts/Mapper.cs create mode 100644 ErsatzTV.Application/Playouts/PlayoutChannelViewModel.cs create mode 100644 ErsatzTV.Application/Playouts/PlayoutItemViewModel.cs create mode 100644 ErsatzTV.Application/Playouts/PlayoutProgramScheduleViewModel.cs create mode 100644 ErsatzTV.Application/Playouts/PlayoutViewModel.cs create mode 100644 ErsatzTV.Application/Playouts/Queries/GetAllPlayouts.cs create mode 100644 ErsatzTV.Application/Playouts/Queries/GetAllPlayoutsHandler.cs create mode 100644 ErsatzTV.Application/Playouts/Queries/GetPlayoutById.cs create mode 100644 ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs create mode 100644 ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsById.cs create mode 100644 ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsByIdHandler.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItemHandler.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramSchedule.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleHandler.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Mapper.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Queries/GetAllProgramSchedules.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Queries/GetAllProgramSchedulesHandler.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleById.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleByIdHandler.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItems.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs create mode 100644 ErsatzTV.Application/Resolutions/FFmpegProfileResolutionViewModel.cs create mode 100644 ErsatzTV.Application/Resolutions/Mapper.cs create mode 100644 ErsatzTV.Application/Resolutions/Queries/GetAllResolutions.cs create mode 100644 ErsatzTV.Application/Resolutions/Queries/GetAllResolutionsHandler.cs create mode 100644 ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs create mode 100644 ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs create mode 100644 ErsatzTV.Application/Streaming/Queries/GetConcatPlaylistByChannelNumber.cs create mode 100644 ErsatzTV.Application/Streaming/Queries/GetConcatPlaylistByChannelNumberHandler.cs create mode 100644 ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs create mode 100644 ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs create mode 100644 ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumber.cs create mode 100644 ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumberHandler.cs create mode 100644 ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs create mode 100644 ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs create mode 100644 ErsatzTV.Application/Validators/GetMemberName.cs create mode 100644 ErsatzTV.Application/Validators/NumericValidation.cs create mode 100644 ErsatzTV.Application/Validators/StringValidation.cs create mode 100644 ErsatzTV.CommandLine/Commands/ChannelCommand.cs create mode 100644 ErsatzTV.CommandLine/Commands/ConfigCommand.cs create mode 100644 ErsatzTV.CommandLine/Commands/FFmpegProfileCommand.cs create mode 100644 ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionClearCommand.cs create mode 100644 ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionCreateCommand.cs create mode 100644 ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionMediaItemsCommand.cs create mode 100644 ErsatzTV.CommandLine/Commands/MediaItemCommandBase.cs create mode 100644 ErsatzTV.CommandLine/Commands/MediaItemsCommand.cs create mode 100644 ErsatzTV.CommandLine/Commands/PlayoutCommand.cs create mode 100644 ErsatzTV.CommandLine/Commands/Schedules/ScheduleAddItemCommand.cs create mode 100644 ErsatzTV.CommandLine/Commands/Schedules/ScheduleCreateCommand.cs create mode 100644 ErsatzTV.CommandLine/Config.cs create mode 100644 ErsatzTV.CommandLine/DesiredResolution.cs create mode 100644 ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj create mode 100644 ErsatzTV.CommandLine/Program.cs create mode 100644 ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj create mode 100644 ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs create mode 100644 ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs create mode 100644 ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs create mode 100644 ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs create mode 100644 ErsatzTV.Core.Tests/Scheduling/RandomizedContentTests.cs create mode 100644 ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs create mode 100644 ErsatzTV.Core/AggregateModels/GenericIntegerId.cs create mode 100644 ErsatzTV.Core/AggregateModels/MediaCollectionSummary.cs create mode 100644 ErsatzTV.Core/BaseError.cs create mode 100644 ErsatzTV.Core/Domain/Channel.cs create mode 100644 ErsatzTV.Core/Domain/ConfigElement.cs create mode 100644 ErsatzTV.Core/Domain/ConfigElementKey.cs create mode 100644 ErsatzTV.Core/Domain/FFmpegProfile.cs create mode 100644 ErsatzTV.Core/Domain/LocalMediaSource.cs create mode 100644 ErsatzTV.Core/Domain/MediaCollection.cs create mode 100644 ErsatzTV.Core/Domain/MediaCollectionEnumeratorState.cs create mode 100644 ErsatzTV.Core/Domain/MediaItem.cs create mode 100644 ErsatzTV.Core/Domain/MediaMetadata.cs create mode 100644 ErsatzTV.Core/Domain/MediaSource.cs create mode 100644 ErsatzTV.Core/Domain/MediaSourceType.cs create mode 100644 ErsatzTV.Core/Domain/MediaType.cs create mode 100644 ErsatzTV.Core/Domain/PlaybackOrder.cs create mode 100644 ErsatzTV.Core/Domain/Playout.cs create mode 100644 ErsatzTV.Core/Domain/PlayoutAnchor.cs create mode 100644 ErsatzTV.Core/Domain/PlayoutItem.cs create mode 100644 ErsatzTV.Core/Domain/PlayoutMode.cs create mode 100644 ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs create mode 100644 ErsatzTV.Core/Domain/PlexMediaSource.cs create mode 100644 ErsatzTV.Core/Domain/PlexMediaSourceConnection.cs create mode 100644 ErsatzTV.Core/Domain/PlexMediaSourceLibrary.cs create mode 100644 ErsatzTV.Core/Domain/ProgramSchedule.cs create mode 100644 ErsatzTV.Core/Domain/ProgramScheduleItem.cs create mode 100644 ErsatzTV.Core/Domain/ProgramScheduleItemDuration.cs create mode 100644 ErsatzTV.Core/Domain/ProgramScheduleItemFlood.cs create mode 100644 ErsatzTV.Core/Domain/ProgramScheduleItemMultiple.cs create mode 100644 ErsatzTV.Core/Domain/ProgramScheduleItemOne.cs create mode 100644 ErsatzTV.Core/Domain/ProgramSchedulePlayoutType.cs create mode 100644 ErsatzTV.Core/Domain/Resolution.cs create mode 100644 ErsatzTV.Core/Domain/ResolutionKey.cs create mode 100644 ErsatzTV.Core/Domain/SimpleMediaCollection.cs create mode 100644 ErsatzTV.Core/Domain/SourceMode.cs create mode 100644 ErsatzTV.Core/Domain/StartType.cs create mode 100644 ErsatzTV.Core/Domain/StreamingMode.cs create mode 100644 ErsatzTV.Core/Domain/TelevisionMediaCollection.cs create mode 100644 ErsatzTV.Core/Domain/VideoScanType.cs create mode 100644 ErsatzTV.Core/ErsatzTV.Core.csproj create mode 100644 ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs create mode 100644 ErsatzTV.Core/FFmpeg/DisplaySize.cs create mode 100644 ErsatzTV.Core/FFmpeg/DisplaySizeExtensions.cs create mode 100644 ErsatzTV.Core/FFmpeg/FFmpegLocator.cs create mode 100644 ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs create mode 100644 ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs create mode 100644 ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs create mode 100644 ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs create mode 100644 ErsatzTV.Core/FileSystemLayout.cs create mode 100644 ErsatzTV.Core/Hdhr/DeviceXml.cs create mode 100644 ErsatzTV.Core/Hdhr/Discover.cs create mode 100644 ErsatzTV.Core/Hdhr/LineupItem.cs create mode 100644 ErsatzTV.Core/Hdhr/LineupStatus.cs create mode 100644 ErsatzTV.Core/Interfaces/FFmpeg/IDisplaySize.cs create mode 100644 ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegLocator.cs create mode 100644 ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs create mode 100644 ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs create mode 100644 ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs create mode 100644 ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs create mode 100644 ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs create mode 100644 ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs create mode 100644 ErsatzTV.Core/Interfaces/Plex/IPlexTvApiClient.cs create mode 100644 ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs create mode 100644 ErsatzTV.Core/Interfaces/Repositories/IConfigElementRepository.cs create mode 100644 ErsatzTV.Core/Interfaces/Repositories/IFFmpegProfileRepository.cs create mode 100644 ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs create mode 100644 ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs create mode 100644 ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs create mode 100644 ErsatzTV.Core/Interfaces/Repositories/IPlayoutRepository.cs create mode 100644 ErsatzTV.Core/Interfaces/Repositories/IProgramScheduleRepository.cs create mode 100644 ErsatzTV.Core/Interfaces/Repositories/IResolutionRepository.cs create mode 100644 ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs create mode 100644 ErsatzTV.Core/Interfaces/Scheduling/IPlayoutBuilder.cs create mode 100644 ErsatzTV.Core/Iptv/ChannelGuide.cs create mode 100644 ErsatzTV.Core/Iptv/ChannelPlaylist.cs create mode 100644 ErsatzTV.Core/LanguageExtensions.cs create mode 100644 ErsatzTV.Core/Metadata/LocalMediaScanner.cs create mode 100644 ErsatzTV.Core/Metadata/LocalMetadataProvider.cs create mode 100644 ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs create mode 100644 ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs create mode 100644 ErsatzTV.Core/Plex/PlexAuthPin.cs create mode 100644 ErsatzTV.Core/Plex/PlexServerAuthToken.cs create mode 100644 ErsatzTV.Core/Plex/PlexUserAuthToken.cs create mode 100644 ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs create mode 100644 ErsatzTV.Core/Scheduling/PlayoutBuilder.cs create mode 100644 ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs create mode 100644 ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/ConfigElementConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/LocalMediaSourceConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionSummaryConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/MediaItemConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/MediaSourceConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/PlayoutConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/PlayoutProgramScheduleAnchorConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/PlexMediaSourceConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/PlexMediaSourceConnectionConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/PlexMediaSourceLibraryConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemDurationConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemFloodConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemMultipleConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemOneConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/SimpleMediaCollectionConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/TelevisionMediaCollectionConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/DbInitializer.cs create mode 100644 ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs create mode 100644 ErsatzTV.Infrastructure/Data/Repositories/ConfigElementRepository.cs create mode 100644 ErsatzTV.Infrastructure/Data/Repositories/FFmpegProfileRepository.cs create mode 100644 ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs create mode 100644 ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs create mode 100644 ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs create mode 100644 ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs create mode 100644 ErsatzTV.Infrastructure/Data/Repositories/ProgramScheduleRepository.cs create mode 100644 ErsatzTV.Infrastructure/Data/Repositories/ResolutionRepository.cs create mode 100644 ErsatzTV.Infrastructure/Data/TvContext.cs create mode 100644 ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj create mode 100644 ErsatzTV.Infrastructure/HttpLoggingHandler.cs create mode 100644 ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs create mode 100644 ErsatzTV.Infrastructure/Plex/IPlexTvApi.cs create mode 100644 ErsatzTV.Infrastructure/Plex/Models/PlexLibraryResponse.cs create mode 100644 ErsatzTV.Infrastructure/Plex/Models/PlexMediaContainerResponse.cs create mode 100644 ErsatzTV.Infrastructure/Plex/Models/PlexPinResponse.cs create mode 100644 ErsatzTV.Infrastructure/Plex/Models/PlexResource.cs create mode 100644 ErsatzTV.Infrastructure/Plex/Models/PlexResourceConnection.cs create mode 100644 ErsatzTV.Infrastructure/Plex/Models/PlexTokenResponse.cs create mode 100644 ErsatzTV.Infrastructure/Plex/Models/PlexUserResponse.cs create mode 100644 ErsatzTV.Infrastructure/Plex/PlexSecretStore.cs create mode 100644 ErsatzTV.Infrastructure/Plex/PlexSecrets.cs create mode 100644 ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs create mode 100644 ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs create mode 100644 ErsatzTV.sln create mode 100644 ErsatzTV.sln.DotSettings create mode 100644 ErsatzTV/App.razor create mode 100644 ErsatzTV/Controllers/Api/ChannelsController.cs create mode 100644 ErsatzTV/Controllers/Api/FFmpegProfileController.cs create mode 100644 ErsatzTV/Controllers/Api/MediaCollectionsController.cs create mode 100644 ErsatzTV/Controllers/Api/MediaItemsController.cs create mode 100644 ErsatzTV/Controllers/Api/MediaSourcesController.cs create mode 100644 ErsatzTV/Controllers/Api/PlayoutController.cs create mode 100644 ErsatzTV/Controllers/Api/ProgramScheduleController.cs create mode 100644 ErsatzTV/Controllers/HdhrController.cs create mode 100644 ErsatzTV/Controllers/InternalController.cs create mode 100644 ErsatzTV/Controllers/IptvController.cs create mode 100644 ErsatzTV/ErsatzTV.csproj create mode 100644 ErsatzTV/Extensions/EitherToActionResult.cs create mode 100644 ErsatzTV/Extensions/HostExtensions.cs create mode 100644 ErsatzTV/Extensions/ListToActionResult.cs create mode 100644 ErsatzTV/Extensions/OptionToActionResult.cs create mode 100644 ErsatzTV/Extensions/ValidationToActionResult.cs create mode 100644 ErsatzTV/Formatters/ChannelGuideOutputFormatter.cs create mode 100644 ErsatzTV/Formatters/ChannelPlaylistOutputFormatter.cs create mode 100644 ErsatzTV/Formatters/ConcatPlaylistOutputFormatter.cs create mode 100644 ErsatzTV/Formatters/DeviceXmlOutputFormatter.cs create mode 100644 ErsatzTV/Formatters/HdhrJsonOutputFormatter.cs create mode 100644 ErsatzTV/Models/UI/MediaItemExtensions.cs create mode 100644 ErsatzTV/Pages/ChannelEditor.razor create mode 100644 ErsatzTV/Pages/Channels.razor create mode 100644 ErsatzTV/Pages/FFmpeg.razor create mode 100644 ErsatzTV/Pages/FFmpegEditor.razor create mode 100644 ErsatzTV/Pages/Index.razor create mode 100644 ErsatzTV/Pages/LocalMediaSourceEditor.razor create mode 100644 ErsatzTV/Pages/MediaCollectionEditor.razor create mode 100644 ErsatzTV/Pages/MediaCollectionItemsEditor.razor create mode 100644 ErsatzTV/Pages/MediaCollections.razor create mode 100644 ErsatzTV/Pages/MediaItems.razor create mode 100644 ErsatzTV/Pages/MediaSources.razor create mode 100644 ErsatzTV/Pages/Playouts.razor create mode 100644 ErsatzTV/Pages/ScheduleEditor.razor create mode 100644 ErsatzTV/Pages/ScheduleItemsEditor.razor create mode 100644 ErsatzTV/Pages/Schedules.razor create mode 100644 ErsatzTV/Pages/_Host.cshtml create mode 100644 ErsatzTV/Program.cs create mode 100644 ErsatzTV/Properties/launchSettings.json create mode 100644 ErsatzTV/Resources/ErsatzTV.png create mode 100644 ErsatzTV/Resources/Roboto-Regular.ttf create mode 100644 ErsatzTV/Resources/background.png create mode 100644 ErsatzTV/Serialization/CustomContractResolver.cs create mode 100644 ErsatzTV/Serialization/CustomNamingStrategy.cs create mode 100644 ErsatzTV/Services/FFmpegLocatorService.cs create mode 100644 ErsatzTV/Services/PlexService.cs create mode 100644 ErsatzTV/Services/SchedulerService.cs create mode 100644 ErsatzTV/Services/WorkerService.cs create mode 100644 ErsatzTV/Shared/DeleteDialog.razor create mode 100644 ErsatzTV/Shared/LocalMediaSources.razor create mode 100644 ErsatzTV/Shared/MainLayout.razor create mode 100644 ErsatzTV/Shared/MainLayout.razor.css create mode 100644 ErsatzTV/Shared/MediaItemTable.razor create mode 100644 ErsatzTV/Shared/PlexMediaSources.razor create mode 100644 ErsatzTV/Startup.cs create mode 100644 ErsatzTV/Validators/ChannelEditViewModelValidator.cs create mode 100644 ErsatzTV/Validators/FFmpegProfileEditViewModelValidator.cs create mode 100644 ErsatzTV/Validators/LocalMediaSourceEditViewModelValidator.cs create mode 100644 ErsatzTV/Validators/ProgramScheduleEditViewModelValidator.cs create mode 100644 ErsatzTV/Validators/SimpleMediaCollectionEditViewModelValidator.cs create mode 100644 ErsatzTV/ViewModels/ChannelEditViewModel.cs create mode 100644 ErsatzTV/ViewModels/FFmpegProfileEditViewModel.cs create mode 100644 ErsatzTV/ViewModels/LocalMediaSourceEditViewModel.cs create mode 100644 ErsatzTV/ViewModels/ProgramScheduleEditViewModel.cs create mode 100644 ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs create mode 100644 ErsatzTV/ViewModels/ProgramScheduleItemsEditViewModel.cs create mode 100644 ErsatzTV/ViewModels/SimpleMediaCollectionEditViewModel.cs create mode 100644 ErsatzTV/_Imports.razor create mode 100644 ErsatzTV/appsettings.Development.json create mode 100644 ErsatzTV/appsettings.json create mode 100644 ErsatzTV/wwwroot/css/bootstrap/bootstrap.min.css create mode 100644 ErsatzTV/wwwroot/css/bootstrap/bootstrap.min.css.map create mode 100644 ErsatzTV/wwwroot/css/open-iconic/FONT-LICENSE create mode 100644 ErsatzTV/wwwroot/css/open-iconic/ICON-LICENSE create mode 100644 ErsatzTV/wwwroot/css/open-iconic/README.md create mode 100644 ErsatzTV/wwwroot/css/open-iconic/font/css/open-iconic-bootstrap.min.css create mode 100644 ErsatzTV/wwwroot/css/open-iconic/font/fonts/open-iconic.eot create mode 100644 ErsatzTV/wwwroot/css/open-iconic/font/fonts/open-iconic.otf create mode 100644 ErsatzTV/wwwroot/css/open-iconic/font/fonts/open-iconic.svg create mode 100644 ErsatzTV/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf create mode 100644 ErsatzTV/wwwroot/css/open-iconic/font/fonts/open-iconic.woff create mode 100644 ErsatzTV/wwwroot/css/site.css create mode 100644 ErsatzTV/wwwroot/images/ersatztv-500.png create mode 100644 LICENSE create mode 100644 README.md create mode 100644 artwork/ersatztv-logo.svg create mode 100644 docker-compose.yml create mode 100644 docs/plex-live-tv-stream.png create mode 100644 generated/ErsatzTV.Api.Sdk/.openapi-generator-ignore create mode 100644 generated/ErsatzTV.Api.Sdk/.openapi-generator/FILES create mode 100644 generated/ErsatzTV.Api.Sdk/.openapi-generator/VERSION create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Api/ChannelsApi.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Api/FFmpegProfileApi.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Api/MediaCollectionsApi.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Api/MediaItemsApi.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Api/MediaSourcesApi.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Api/PlayoutApi.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Api/ProgramScheduleApi.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/ApiClient.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/ApiException.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/ApiResponse.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/ClientUtils.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/Configuration.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/ExceptionFactory.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/GlobalConfiguration.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/HttpMethod.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/IApiAccessor.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/IAsynchronousClient.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/IReadableConfiguration.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/ISynchronousClient.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/Multimap.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/OpenAPIDateConverter.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/RequestOptions.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Client/RetryConfiguration.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/ErsatzTV.Api.Sdk.csproj create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/AbstractOpenAPISchema.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/AddProgramScheduleItem.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/ChannelViewModel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/CreateChannel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/CreateFFmpegProfile.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/CreateMediaItem.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/CreatePlayout.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/CreateProgramSchedule.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/CreateSimpleMediaCollection.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/DeleteChannel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/DeleteFFmpegProfile.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/DeleteMediaItem.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/DeletePlayout.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/DeleteProgramSchedule.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/FFmpegProfileViewModel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/MediaCollectionViewModel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/MediaItemViewModel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/MediaSourceType.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/MediaSourceViewModel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/PlaybackOrder.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/PlayoutChannelViewModel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/PlayoutMode.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/PlayoutProgramScheduleViewModel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/PlayoutViewModel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/ProblemDetails.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/ProgramScheduleItemViewModel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/ProgramSchedulePlayoutType.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/ProgramScheduleViewModel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/ReplaceProgramScheduleItem.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/ResolutionViewModel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/StartType.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/StreamingMode.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/UpdateChannel.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/UpdateFFmpegProfile.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/UpdatePlayout.cs create mode 100644 generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/UpdateProgramSchedule.cs create mode 100755 scripts/cleanup-code.sh create mode 100644 scripts/generate-api-sdk/.openapi-generator-ignore create mode 100755 scripts/generate-api-sdk/generate-api-sdk.sh create mode 100644 scripts/generate-api-sdk/openapitools.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 000000000..c8f4c746f --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "jetbrains.resharper.globaltools": { + "version": "2020.3.2", + "commands": [ + "jb" + ] + }, + "swashbuckle.aspnetcore.cli": { + "version": "5.6.2", + "commands": [ + "swagger" + ] + } + } +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..d790afa29 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +docs/ +sdk/ +**/.vs/ +**/bin/ +**/obj/ +.idea/ +Dockerfile \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..3082d4e6d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,81 @@ + +[*] +charset=utf-8-bom +end_of_line=lf +trim_trailing_whitespace=false +insert_final_newline=false +indent_style=space +indent_size=4 + +# Microsoft .NET properties +csharp_new_line_before_members_in_object_initializers=false +csharp_preferred_modifier_order=public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion +csharp_style_expression_bodied_accessors=true:suggestion +csharp_style_expression_bodied_constructors=true:none +csharp_style_expression_bodied_methods=true:none +csharp_style_expression_bodied_properties=true:suggestion +csharp_style_var_elsewhere=false:suggestion +csharp_style_var_for_built_in_types=false:suggestion +csharp_style_var_when_type_is_apparent=true:suggestion +dotnet_naming_rule.local_constants_rule.severity=warning +dotnet_naming_rule.local_constants_rule.style=all_upper_style +dotnet_naming_rule.local_constants_rule.symbols=local_constants_symbols +dotnet_naming_style.all_upper_style.capitalization=all_upper +dotnet_naming_style.all_upper_style.word_separator=_ +dotnet_naming_symbols.local_constants_symbols.applicable_accessibilities=* +dotnet_naming_symbols.local_constants_symbols.applicable_kinds=local +dotnet_naming_symbols.local_constants_symbols.required_modifiers=const +dotnet_style_parentheses_in_arithmetic_binary_operators=never_if_unnecessary:none +dotnet_style_parentheses_in_other_binary_operators=never_if_unnecessary:none +dotnet_style_parentheses_in_relational_binary_operators=never_if_unnecessary:none +dotnet_style_predefined_type_for_locals_parameters_members=true:suggestion +dotnet_style_predefined_type_for_member_access=true:suggestion +dotnet_style_qualification_for_event=false:suggestion +dotnet_style_qualification_for_field=false:suggestion +dotnet_style_qualification_for_method=false:suggestion +dotnet_style_qualification_for_property=false:suggestion +dotnet_style_require_accessibility_modifiers=for_non_interface_members:suggestion + +# ReSharper properties +resharper_autodetect_indent_settings=true +resharper_braces_for_for=required +resharper_braces_for_foreach=required +resharper_braces_for_ifelse=required +resharper_braces_for_while=required +resharper_csharp_insert_final_newline=true +resharper_csharp_max_attribute_length_for_same_line=0 +resharper_csharp_place_accessorholder_attribute_on_same_line=never +resharper_csharp_place_field_attribute_on_same_line=if_owner_is_single_line +resharper_csharp_wrap_after_declaration_lpar=true +resharper_csharp_wrap_after_invocation_lpar=true +resharper_csharp_wrap_arguments_style=chop_if_long +resharper_csharp_wrap_parameters_style=chop_if_long +resharper_enforce_line_ending_style=true +resharper_for_built_in_types=use_var_when_evident +resharper_space_within_single_line_array_initializer_braces=true +resharper_use_indent_from_vs=false +resharper_wrap_array_initializer_style=chop_if_long + +# ReSharper inspection severities +resharper_arrange_redundant_parentheses_highlighting=hint +resharper_arrange_this_qualifier_highlighting=hint +resharper_arrange_type_member_modifiers_highlighting=hint +resharper_arrange_type_modifiers_highlighting=hint +resharper_built_in_type_reference_style_for_member_access_highlighting=hint +resharper_built_in_type_reference_style_highlighting=hint +resharper_redundant_base_qualifier_highlighting=warning +resharper_suggest_var_or_type_built_in_types_highlighting=hint +resharper_suggest_var_or_type_elsewhere_highlighting=hint +resharper_suggest_var_or_type_simple_types_highlighting=hint +resharper_web_config_module_not_resolved_highlighting=warning +resharper_web_config_type_not_resolved_highlighting=warning +resharper_web_config_wrong_module_highlighting=warning + +[{*.har,*.inputactions,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.stylelintrc,bowerrc,jest.config}] +indent_style=space +indent_size=2 + +[*.{appxmanifest,asax,ascx,aspx,build,cg,cginc,compute,cs,cshtml,dtd,fs,fsi,fsscript,fsx,hlsl,hlsli,hlslinc,master,ml,mli,nuspec,razor,resw,resx,shader,skin,usf,ush,vb,xaml,xamlx,xoml,xsd}] +indent_style=space +indent_size=4 +tab_width=4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..c5b62138c --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +*.swp +*.*~ +project.lock.json +.DS_Store +*.pyc +nupkg/ + +# Visual Studio Code +.vscode + +# Rider +.idea + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +msbuild.log +msbuild.err +msbuild.wrn + +# Visual Studio 2015 +.vs/ + +*.sqlite3* +core + +scripts/generate-api-sdk/swagger.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..6ede522ad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS runtime-base +RUN apt-get update && apt-get install -y ffmpeg + +# https://hub.docker.com/_/microsoft-dotnet +FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build +WORKDIR /source + +# copy csproj and restore as distinct layers +COPY *.sln . +COPY ErsatzTV/*.csproj ./ErsatzTV/ +COPY ErsatzTV.Tests/*.csproj ./ErsatzTV.Tests/ +RUN dotnet restore -r linux-x64 + +# copy everything else and build app +COPY ErsatzTV/. ./ErsatzTV/ +WORKDIR /source/ErsatzTV +RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore + +# final stage/image +FROM runtime-base +WORKDIR /app +COPY --from=build /app ./ +ENTRYPOINT ["./ErsatzTV"] diff --git a/ErsatzTV.Application/Channels/ChannelViewModel.cs b/ErsatzTV.Application/Channels/ChannelViewModel.cs new file mode 100644 index 000000000..4670214fe --- /dev/null +++ b/ErsatzTV.Application/Channels/ChannelViewModel.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Channels +{ + public record ChannelViewModel( + int Id, + int Number, + string Name, + int FFmpegProfileId, + string Logo, + StreamingMode StreamingMode); +} diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannel.cs b/ErsatzTV.Application/Channels/Commands/CreateChannel.cs new file mode 100644 index 000000000..4848f8d73 --- /dev/null +++ b/ErsatzTV.Application/Channels/Commands/CreateChannel.cs @@ -0,0 +1,15 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Channels.Commands +{ + public record CreateChannel + ( + string Name, + int Number, + int FFmpegProfileId, + string Logo, + StreamingMode StreamingMode) : IRequest>; +} diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs new file mode 100644 index 000000000..2d1dac842 --- /dev/null +++ b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs @@ -0,0 +1,58 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Channels.Mapper; + +namespace ErsatzTV.Application.Channels.Commands +{ + public class CreateChannelHandler : IRequestHandler> + { + private readonly IChannelRepository _channelRepository; + private readonly IFFmpegProfileRepository _ffmpegProfileRepository; + + public CreateChannelHandler( + IChannelRepository channelRepository, + IFFmpegProfileRepository ffmpegProfileRepository) + { + _channelRepository = channelRepository; + _ffmpegProfileRepository = ffmpegProfileRepository; + } + + public Task> Handle( + CreateChannel request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(PersistChannel) + .Bind(v => v.ToEitherAsync()); + + private Task PersistChannel(Channel c) => + _channelRepository.Add(c).Map(ProjectToViewModel); + + private async Task> Validate(CreateChannel request) => + (ValidateName(request), ValidateNumber(request), await FFmpegProfileMustExist(request)) + .Apply( + (name, number, ffmpegProfileId) => new Channel(Guid.NewGuid()) + { + Name = name, Number = number, FFmpegProfileId = ffmpegProfileId, + StreamingMode = request.StreamingMode + }); + + private Validation ValidateName(CreateChannel createChannel) => + createChannel.NotEmpty(c => c.Name) + .Bind(_ => createChannel.NotLongerThan(50)(c => c.Name)); + + // TODO: validate number does not exist? + private Validation ValidateNumber(CreateChannel createChannel) => + createChannel.AtLeast(1)(c => c.Number); + + private async Task> FFmpegProfileMustExist(CreateChannel createChannel) => + (await _ffmpegProfileRepository.Get(createChannel.FFmpegProfileId)) + .ToValidation($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist.") + .Map(c => c.Id); + } +} diff --git a/ErsatzTV.Application/Channels/Commands/DeleteChannel.cs b/ErsatzTV.Application/Channels/Commands/DeleteChannel.cs new file mode 100644 index 000000000..76fd60299 --- /dev/null +++ b/ErsatzTV.Application/Channels/Commands/DeleteChannel.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Channels.Commands +{ + public record DeleteChannel(int ChannelId) : IRequest>; +} diff --git a/ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs new file mode 100644 index 000000000..83b019d7d --- /dev/null +++ b/ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs @@ -0,0 +1,28 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Channels.Commands +{ + public class DeleteChannelHandler : IRequestHandler> + { + private readonly IChannelRepository _channelRepository; + + public DeleteChannelHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository; + + public async Task> Handle(DeleteChannel request, CancellationToken cancellationToken) => + (await ChannelMustExist(request)) + .Map(DoDeletion) + .ToEither(); + + private Task DoDeletion(int channelId) => _channelRepository.Delete(channelId); + + private async Task> ChannelMustExist(DeleteChannel deleteChannel) => + (await _channelRepository.Get(deleteChannel.ChannelId)) + .ToValidation($"Channel {deleteChannel.ChannelId} does not exist.") + .Map(c => c.Id); + } +} diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs new file mode 100644 index 000000000..eea4b568a --- /dev/null +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs @@ -0,0 +1,16 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Channels.Commands +{ + public record UpdateChannel + ( + int ChannelId, + string Name, + int Number, + int FFmpegProfileId, + string Logo, + StreamingMode StreamingMode) : IRequest>; +} diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs new file mode 100644 index 000000000..e63f59d79 --- /dev/null +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs @@ -0,0 +1,60 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Channels.Mapper; + +namespace ErsatzTV.Application.Channels.Commands +{ + public class UpdateChannelHandler : IRequestHandler> + { + private readonly IChannelRepository _channelRepository; + + public UpdateChannelHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository; + + public Task> Handle( + UpdateChannel request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(c => ApplyUpdateRequest(c, request)) + .Bind(v => v.ToEitherAsync()); + + private async Task ApplyUpdateRequest(Channel c, UpdateChannel update) + { + c.Name = update.Name; + c.Number = update.Number; + c.FFmpegProfileId = update.FFmpegProfileId; + c.Logo = update.Logo; + c.StreamingMode = update.StreamingMode; + await _channelRepository.Update(c); + return ProjectToViewModel(c); + } + + private async Task> Validate(UpdateChannel request) => + (await ChannelMustExist(request), ValidateName(request), await ValidateNumber(request)) + .Apply((channelToUpdate, _, _) => channelToUpdate); + + private Task> ChannelMustExist(UpdateChannel updateChannel) => + _channelRepository.Get(updateChannel.ChannelId) + .Map(v => v.ToValidation("Channel does not exist.")); + + private Validation ValidateName(UpdateChannel updateChannel) => + updateChannel.NotEmpty(c => c.Name) + .Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name)); + + private async Task> ValidateNumber(UpdateChannel updateChannel) + { + Option match = await _channelRepository.GetByNumber(updateChannel.Number); + int matchId = match.Map(c => c.Id).IfNone(updateChannel.ChannelId); + if (matchId == updateChannel.ChannelId) + { + return updateChannel.AtLeast(1)(c => c.Number); + } + + return BaseError.New("Channel number must be unique"); + } + } +} diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs new file mode 100644 index 000000000..4e86ae756 --- /dev/null +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Channels +{ + internal static class Mapper + { + internal static ChannelViewModel ProjectToViewModel(Channel channel) => + new(channel.Id, channel.Number, channel.Name, channel.FFmpegProfileId, channel.Logo, channel.StreamingMode); + } +} diff --git a/ErsatzTV.Application/Channels/Queries/GetAllChannels.cs b/ErsatzTV.Application/Channels/Queries/GetAllChannels.cs new file mode 100644 index 000000000..99c5d0f08 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetAllChannels.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.Channels.Queries +{ + public record GetAllChannels : IRequest>; +} diff --git a/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs b/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs new file mode 100644 index 000000000..737e933d0 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Channels.Mapper; + +namespace ErsatzTV.Application.Channels.Queries +{ + public class GetAllChannelsHandler : IRequestHandler> + { + private readonly IChannelRepository _channelRepository; + + public GetAllChannelsHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository; + + public Task> Handle(GetAllChannels request, CancellationToken cancellationToken) => + _channelRepository.GetAll().Map(channels => channels.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelById.cs b/ErsatzTV.Application/Channels/Queries/GetChannelById.cs new file mode 100644 index 000000000..696ee2e45 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Channels.Queries +{ + public record GetChannelById(int Id) : IRequest>; +} diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelByIdHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelByIdHandler.cs new file mode 100644 index 000000000..8770c9671 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelByIdHandler.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Channels.Mapper; + +namespace ErsatzTV.Application.Channels.Queries +{ + public class GetChannelByIdHandler : IRequestHandler> + { + private readonly IChannelRepository _channelRepository; + + public GetChannelByIdHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository; + + public Task> Handle(GetChannelById request, CancellationToken cancellationToken) => + _channelRepository.Get(request.Id) + .MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs new file mode 100644 index 000000000..5d37ca95c --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core.Iptv; +using MediatR; + +namespace ErsatzTV.Application.Channels.Queries +{ + public record GetChannelGuide(string Scheme, string Host) : IRequest; +} diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs new file mode 100644 index 000000000..8b07dadd9 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Iptv; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Channels.Queries +{ + public class GetChannelGuideHandler : IRequestHandler + { + private readonly IChannelRepository _channelRepository; + + public GetChannelGuideHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository; + + public Task Handle(GetChannelGuide request, CancellationToken cancellationToken) => + _channelRepository.GetAllForGuide() + .Map(channels => new ChannelGuide(request.Scheme, request.Host, channels)); + } +} diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelLineup.cs b/ErsatzTV.Application/Channels/Queries/GetChannelLineup.cs new file mode 100644 index 000000000..979522d53 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelLineup.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; +using ErsatzTV.Core.Hdhr; +using MediatR; + +namespace ErsatzTV.Application.Channels.Queries +{ + public record GetChannelLineup(string Scheme, string Host) : IRequest>; +} diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelLineupHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelLineupHandler.cs new file mode 100644 index 000000000..c43e17c21 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelLineupHandler.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Hdhr; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Channels.Queries +{ + public class GetChannelLineupHandler : IRequestHandler> + { + private readonly IChannelRepository _channelRepository; + + public GetChannelLineupHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository; + + public Task> Handle(GetChannelLineup request, CancellationToken cancellationToken) => + _channelRepository.GetAll() + .Map(channels => channels.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList()); + } +} diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelPlaylist.cs b/ErsatzTV.Application/Channels/Queries/GetChannelPlaylist.cs new file mode 100644 index 000000000..21c830395 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelPlaylist.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core.Iptv; +using MediatR; + +namespace ErsatzTV.Application.Channels.Queries +{ + public record GetChannelPlaylist(string Scheme, string Host) : IRequest; +} diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs new file mode 100644 index 000000000..d58f08d01 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Iptv; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Channels.Queries +{ + public class GetChannelPlaylistHandler : IRequestHandler + { + private readonly IChannelRepository _channelRepository; + + public GetChannelPlaylistHandler(IChannelRepository channelRepository) => + _channelRepository = channelRepository; + + public Task Handle(GetChannelPlaylist request, CancellationToken cancellationToken) => + _channelRepository.GetAll() + .Map(channels => new ChannelPlaylist(request.Scheme, request.Host, channels)); + } +} diff --git a/ErsatzTV.Application/ErsatzTV.Application.csproj b/ErsatzTV.Application/ErsatzTV.Application.csproj new file mode 100644 index 000000000..e8685b7b3 --- /dev/null +++ b/ErsatzTV.Application/ErsatzTV.Application.csproj @@ -0,0 +1,18 @@ + + + + net5.0 + + + + + + + + + + + + + + diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs new file mode 100644 index 000000000..8d4bdea6e --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs @@ -0,0 +1,25 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.FFmpegProfiles.Commands +{ + public record CreateFFmpegProfile( + string Name, + int ThreadCount, + bool Transcode, + int ResolutionId, + bool NormalizeResolution, + string VideoCodec, + bool NormalizeVideoCodec, + int VideoBitrate, + int VideoBufferSize, + string AudioCodec, + bool NormalizeAudioCodec, + int AudioBitrate, + int AudioBufferSize, + int AudioVolume, + int AudioChannels, + int AudioSampleRate, + bool NormalizeAudio) : IRequest>; +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs new file mode 100644 index 000000000..aecb82b4e --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs @@ -0,0 +1,72 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.FFmpegProfiles.Mapper; + +namespace ErsatzTV.Application.FFmpegProfiles.Commands +{ + public class + CreateFFmpegProfileHandler : IRequestHandler> + { + private readonly IFFmpegProfileRepository _ffmpegProfileRepository; + private readonly IResolutionRepository _resolutionRepository; + + public CreateFFmpegProfileHandler( + IFFmpegProfileRepository ffmpegProfileRepository, + IResolutionRepository resolutionRepository) + { + _ffmpegProfileRepository = ffmpegProfileRepository; + _resolutionRepository = resolutionRepository; + } + + public Task> Handle( + CreateFFmpegProfile request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(PersistFFmpegProfile) + .Bind(v => v.ToEitherAsync()); + + private Task PersistFFmpegProfile(FFmpegProfile ffmpegProfile) => + _ffmpegProfileRepository.Add(ffmpegProfile).Map(ProjectToViewModel); + + private async Task> Validate(CreateFFmpegProfile request) => + (ValidateName(request), ValidateThreadCount(request), await ResolutionMustExist(request)) + .Apply( + (name, threadCount, resolutionId) => new FFmpegProfile + { + Name = name, + ThreadCount = threadCount, + Transcode = request.Transcode, + ResolutionId = resolutionId, + NormalizeResolution = request.NormalizeResolution, + VideoCodec = request.VideoCodec, + NormalizeVideoCodec = request.NormalizeVideoCodec, + VideoBitrate = request.VideoBitrate, + VideoBufferSize = request.VideoBufferSize, + AudioCodec = request.AudioCodec, + NormalizeAudioCodec = request.NormalizeAudioCodec, + AudioBitrate = request.AudioBitrate, + AudioBufferSize = request.AudioBufferSize, + AudioVolume = request.AudioVolume, + AudioChannels = request.AudioChannels, + AudioSampleRate = request.AudioSampleRate, + NormalizeAudio = request.NormalizeAudio + }); + + private Validation ValidateName(CreateFFmpegProfile createFFmpegProfile) => + createFFmpegProfile.NotEmpty(x => x.Name) + .Bind(_ => createFFmpegProfile.NotLongerThan(50)(x => x.Name)); + + private Validation ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) => + createFFmpegProfile.AtLeast(1)(p => p.ThreadCount); + + private async Task> ResolutionMustExist(CreateFFmpegProfile createFFmpegProfile) => + (await _resolutionRepository.Get(createFFmpegProfile.ResolutionId)) + .ToValidation($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist.") + .Map(c => c.Id); + } +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfile.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfile.cs new file mode 100644 index 000000000..b7d8f9d63 --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfile.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.FFmpegProfiles.Commands +{ + public record DeleteFFmpegProfile(int FFmpegProfileId) : IRequest>; +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs new file mode 100644 index 000000000..d0bb36381 --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.FFmpegProfiles.Commands +{ + public class DeleteFFmpegProfileHandler : IRequestHandler> + { + private readonly IFFmpegProfileRepository _ffmpegProfileRepository; + + public DeleteFFmpegProfileHandler(IFFmpegProfileRepository ffmpegProfileRepository) => + _ffmpegProfileRepository = ffmpegProfileRepository; + + public async Task> Handle( + DeleteFFmpegProfile request, + CancellationToken cancellationToken) => + (await FFmpegProfileMustExist(request)) + .Map(DoDeletion) + .ToEither(); + + private Task DoDeletion(int channelId) => _ffmpegProfileRepository.Delete(channelId); + + private async Task> FFmpegProfileMustExist( + DeleteFFmpegProfile deleteFFmpegProfile) => + (await _ffmpegProfileRepository.Get(deleteFFmpegProfile.FFmpegProfileId)) + .ToValidation($"FFmpegProfile {deleteFFmpegProfile.FFmpegProfileId} does not exist.") + .Map(c => c.Id); + } +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/NewFFmpegProfile.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/NewFFmpegProfile.cs new file mode 100644 index 000000000..1bcd94bee --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/NewFFmpegProfile.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace ErsatzTV.Application.FFmpegProfiles.Commands +{ + /// + /// Requests a new ffmpeg profile (view model) that contains + /// appropriate default values. + /// + public record NewFFmpegProfile : IRequest; +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/NewFFmpegProfileHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/NewFFmpegProfileHandler.cs new file mode 100644 index 000000000..4c078d52f --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/NewFFmpegProfileHandler.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static LanguageExt.Prelude; +using static ErsatzTV.Application.FFmpegProfiles.Mapper; + +namespace ErsatzTV.Application.FFmpegProfiles.Commands +{ + public class NewFFmpegProfileHandler : IRequestHandler + { + private readonly IConfigElementRepository _configElementRepository; + private readonly IResolutionRepository _resolutionRepository; + + public NewFFmpegProfileHandler( + IResolutionRepository resolutionRepository, + IConfigElementRepository configElementRepository) + { + _resolutionRepository = resolutionRepository; + _configElementRepository = configElementRepository; + } + + public async Task Handle(NewFFmpegProfile request, CancellationToken cancellationToken) + { + int defaultResolutionId = await _configElementRepository + .GetValue(ConfigElementKey.FFmpegDefaultResolutionId) + .IfNoneAsync(0); + + List allResolutions = await _resolutionRepository.GetAll(); + + Option maybeDefaultResolution = allResolutions.Find(r => r.Id == defaultResolutionId); + Resolution defaultResolution = maybeDefaultResolution.Match(identity, () => allResolutions.Head()); + + return ProjectToViewModel(FFmpegProfile.New("New Profile", defaultResolution)); + } + } +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs new file mode 100644 index 000000000..b65e56632 --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs @@ -0,0 +1,26 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.FFmpegProfiles.Commands +{ + public record UpdateFFmpegProfile( + int FFmpegProfileId, + string Name, + int ThreadCount, + bool Transcode, + int ResolutionId, + bool NormalizeResolution, + string VideoCodec, + bool NormalizeVideoCodec, + int VideoBitrate, + int VideoBufferSize, + string AudioCodec, + bool NormalizeAudioCodec, + int AudioBitrate, + int AudioBufferSize, + int AudioVolume, + int AudioChannels, + int AudioSampleRate, + bool NormalizeAudio) : IRequest>; +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs new file mode 100644 index 000000000..1bae3326d --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs @@ -0,0 +1,78 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.FFmpegProfiles.Mapper; + +namespace ErsatzTV.Application.FFmpegProfiles.Commands +{ + public class + UpdateFFmpegProfileHandler : IRequestHandler> + { + private readonly IFFmpegProfileRepository _ffmpegProfileRepository; + private readonly IResolutionRepository _resolutionRepository; + + public UpdateFFmpegProfileHandler( + IFFmpegProfileRepository ffmpegProfileRepository, + IResolutionRepository resolutionRepository) + { + _ffmpegProfileRepository = ffmpegProfileRepository; + _resolutionRepository = resolutionRepository; + } + + public Task> Handle( + UpdateFFmpegProfile request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(c => ApplyUpdateRequest(c, request)) + .Bind(v => v.ToEitherAsync()); + + private async Task ApplyUpdateRequest(FFmpegProfile p, UpdateFFmpegProfile update) + { + p.Name = update.Name; + p.ThreadCount = update.ThreadCount; + p.Transcode = update.Transcode; + p.ResolutionId = update.ResolutionId; + p.NormalizeResolution = update.NormalizeResolution; + p.VideoCodec = update.VideoCodec; + p.NormalizeVideoCodec = update.NormalizeVideoCodec; + p.VideoBitrate = update.VideoBitrate; + p.VideoBufferSize = update.VideoBufferSize; + p.AudioCodec = update.AudioCodec; + p.NormalizeAudioCodec = update.NormalizeAudioCodec; + p.AudioBitrate = update.AudioBitrate; + p.AudioBufferSize = update.AudioBufferSize; + p.AudioVolume = update.AudioVolume; + p.AudioChannels = update.AudioChannels; + p.AudioSampleRate = update.AudioSampleRate; + p.NormalizeAudio = update.NormalizeAudio; + await _ffmpegProfileRepository.Update(p); + return ProjectToViewModel(p); + } + + private async Task> Validate(UpdateFFmpegProfile request) => + (await FFmpegProfileMustExist(request), ValidateName(request), ValidateThreadCount(request), + await ResolutionMustExist(request)) + .Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate); + + private async Task> FFmpegProfileMustExist( + UpdateFFmpegProfile updateFFmpegProfile) => + (await _ffmpegProfileRepository.Get(updateFFmpegProfile.FFmpegProfileId)) + .ToValidation("FFmpegProfile does not exist."); + + private Validation ValidateName(UpdateFFmpegProfile updateFFmpegProfile) => + updateFFmpegProfile.NotEmpty(x => x.Name) + .Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name)); + + private Validation ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) => + updateFFmpegProfile.AtLeast(1)(p => p.ThreadCount); + + private async Task> ResolutionMustExist(UpdateFFmpegProfile updateFFmpegProfile) => + (await _resolutionRepository.Get(updateFFmpegProfile.ResolutionId)) + .ToValidation($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist.") + .Map(c => c.Id); + } +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettings.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettings.cs new file mode 100644 index 000000000..44d65d3a3 --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettings.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace ErsatzTV.Application.FFmpegProfiles.Commands +{ + public record UpdateFFmpegSettings(FFmpegSettingsViewModel Settings) : IRequest; +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs new file mode 100644 index 000000000..ab4dec619 --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs @@ -0,0 +1,70 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using Unit = MediatR.Unit; + +namespace ErsatzTV.Application.FFmpegProfiles.Commands +{ + public class UpdateFFmpegSettingsHandler : IRequestHandler + { + private readonly IConfigElementRepository _configElementRepository; + + public UpdateFFmpegSettingsHandler(IConfigElementRepository configElementRepository) => + _configElementRepository = configElementRepository; + + public async Task Handle(UpdateFFmpegSettings request, CancellationToken cancellationToken) + { + Option ffmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath); + Option ffprobePath = await _configElementRepository.Get(ConfigElementKey.FFprobePath); + Option defaultFFmpegProfileId = + await _configElementRepository.Get(ConfigElementKey.FFmpegDefaultProfileId); + + ffmpegPath.Match( + ce => + { + ce.Value = request.Settings.FFmpegPath; + _configElementRepository.Update(ce); + }, + () => + { + var ce = new ConfigElement + { Key = ConfigElementKey.FFmpegPath.Key, Value = request.Settings.FFmpegPath }; + _configElementRepository.Add(ce); + }); + + ffprobePath.Match( + ce => + { + ce.Value = request.Settings.FFprobePath; + _configElementRepository.Update(ce); + }, + () => + { + var ce = new ConfigElement + { Key = ConfigElementKey.FFprobePath.Key, Value = request.Settings.FFprobePath }; + _configElementRepository.Add(ce); + }); + + defaultFFmpegProfileId.Match( + ce => + { + ce.Value = request.Settings.DefaultFFmpegProfileId.ToString(); + _configElementRepository.Update(ce); + }, + () => + { + var ce = new ConfigElement + { + Key = ConfigElementKey.FFmpegDefaultProfileId.Key, + Value = request.Settings.DefaultFFmpegProfileId.ToString() + }; + _configElementRepository.Add(ce); + }); + + return Unit.Value; + } + } +} diff --git a/ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs b/ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs new file mode 100644 index 000000000..db0f993b5 --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs @@ -0,0 +1,24 @@ +using ErsatzTV.Application.Resolutions; + +namespace ErsatzTV.Application.FFmpegProfiles +{ + public record FFmpegProfileViewModel( + int Id, + string Name, + int ThreadCount, + bool Transcode, + ResolutionViewModel Resolution, + bool NormalizeResolution, + string VideoCodec, + bool NormalizeVideoCodec, + int VideoBitrate, + int VideoBufferSize, + string AudioCodec, + bool NormalizeAudioCodec, + int AudioBitrate, + int AudioBufferSize, + int AudioVolume, + int AudioChannels, + int AudioSampleRate, + bool NormalizeAudio); +} diff --git a/ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs b/ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs new file mode 100644 index 000000000..6bf3f9cdd --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Application.FFmpegProfiles +{ + public class FFmpegSettingsViewModel + { + public string FFmpegPath { get; set; } + public string FFprobePath { get; set; } + public int DefaultFFmpegProfileId { get; set; } + } +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Mapper.cs b/ErsatzTV.Application/FFmpegProfiles/Mapper.cs new file mode 100644 index 000000000..167285325 --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Mapper.cs @@ -0,0 +1,32 @@ +using ErsatzTV.Application.Resolutions; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.FFmpegProfiles +{ + internal static class Mapper + { + internal static FFmpegProfileViewModel ProjectToViewModel(FFmpegProfile profile) => + new( + profile.Id, + profile.Name, + profile.ThreadCount, + profile.Transcode, + Project(profile.Resolution), + profile.NormalizeResolution, + profile.VideoCodec, + profile.NormalizeVideoCodec, + profile.VideoBitrate, + profile.VideoBufferSize, + profile.AudioCodec, + profile.NormalizeAudioCodec, + profile.AudioBitrate, + profile.AudioBufferSize, + profile.AudioVolume, + profile.AudioChannels, + profile.AudioSampleRate, + profile.NormalizeAudio); + + private static ResolutionViewModel Project(Resolution resolution) => + new(resolution.Id, resolution.Name, resolution.Width, resolution.Height); + } +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Queries/GetAllFFmpegProfiles.cs b/ErsatzTV.Application/FFmpegProfiles/Queries/GetAllFFmpegProfiles.cs new file mode 100644 index 000000000..90a3fdba0 --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Queries/GetAllFFmpegProfiles.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.FFmpegProfiles.Queries +{ + public record GetAllFFmpegProfiles : IRequest>; +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Queries/GetAllFFmpegProfilesHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Queries/GetAllFFmpegProfilesHandler.cs new file mode 100644 index 000000000..a28778269 --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Queries/GetAllFFmpegProfilesHandler.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using MediatR; +using static ErsatzTV.Application.FFmpegProfiles.Mapper; + +namespace ErsatzTV.Application.FFmpegProfiles.Queries +{ + public class GetAllFFmpegProfilesHandler : IRequestHandler> + { + private readonly IFFmpegProfileRepository _ffmpegProfileRepository; + + public GetAllFFmpegProfilesHandler(IFFmpegProfileRepository ffmpegProfileRepository) => + _ffmpegProfileRepository = ffmpegProfileRepository; + + public async Task> Handle( + GetAllFFmpegProfiles request, + CancellationToken cancellationToken) => + (await _ffmpegProfileRepository.GetAll()).Map(ProjectToViewModel).ToList(); + } +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileById.cs b/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileById.cs new file mode 100644 index 000000000..206a19422 --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.FFmpegProfiles.Queries +{ + public record GetFFmpegProfileById(int Id) : IRequest>; +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdHandler.cs new file mode 100644 index 000000000..a4e6ecd48 --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdHandler.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.FFmpegProfiles.Mapper; + +namespace ErsatzTV.Application.FFmpegProfiles.Queries +{ + public class GetFFmpegProfileByIdHandler : IRequestHandler> + { + private readonly IFFmpegProfileRepository _ffmpegProfileRepository; + + public GetFFmpegProfileByIdHandler(IFFmpegProfileRepository ffmpegProfileRepository) => + _ffmpegProfileRepository = ffmpegProfileRepository; + + public Task> Handle( + GetFFmpegProfileById request, + CancellationToken cancellationToken) => + _ffmpegProfileRepository.Get(request.Id) + .MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettings.cs b/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettings.cs new file mode 100644 index 000000000..167b7bf63 --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettings.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace ErsatzTV.Application.FFmpegProfiles.Queries +{ + public record GetFFmpegSettings : IRequest; +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs new file mode 100644 index 000000000..0037ae66a --- /dev/null +++ b/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs @@ -0,0 +1,34 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.FFmpegProfiles.Queries +{ + public class GetFFmpegSettingsHandler : IRequestHandler + { + private readonly IConfigElementRepository _configElementRepository; + + public GetFFmpegSettingsHandler(IConfigElementRepository configElementRepository) => + _configElementRepository = configElementRepository; + + public async Task Handle( + GetFFmpegSettings request, + CancellationToken cancellationToken) + { + Option ffmpegPath = await _configElementRepository.GetValue(ConfigElementKey.FFmpegPath); + Option ffprobePath = await _configElementRepository.GetValue(ConfigElementKey.FFprobePath); + Option defaultFFmpegProfileId = + await _configElementRepository.GetValue(ConfigElementKey.FFmpegDefaultProfileId); + + return new FFmpegSettingsViewModel + { + FFmpegPath = ffmpegPath.IfNone(string.Empty), + FFprobePath = ffprobePath.IfNone(string.Empty), + DefaultFFmpegProfileId = defaultFFmpegProfileId.IfNone(0) + }; + } + } +} diff --git a/ErsatzTV.Application/IBackgroundServiceRequest.cs b/ErsatzTV.Application/IBackgroundServiceRequest.cs new file mode 100644 index 000000000..7854670db --- /dev/null +++ b/ErsatzTV.Application/IBackgroundServiceRequest.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Application +{ + public interface IBackgroundServiceRequest + { + } +} diff --git a/ErsatzTV.Application/IPlexBackgroundServiceRequest.cs b/ErsatzTV.Application/IPlexBackgroundServiceRequest.cs new file mode 100644 index 000000000..b80ccea84 --- /dev/null +++ b/ErsatzTV.Application/IPlexBackgroundServiceRequest.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Application +{ + public interface IPlexBackgroundServiceRequest + { + } +} diff --git a/ErsatzTV.Application/Images/Commands/SaveImageToDisk.cs b/ErsatzTV.Application/Images/Commands/SaveImageToDisk.cs new file mode 100644 index 000000000..68873ff05 --- /dev/null +++ b/ErsatzTV.Application/Images/Commands/SaveImageToDisk.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Images.Commands +{ + // ReSharper disable once SuggestBaseTypeForParameter + public record SaveImageToDisk(byte[] Buffer) : IRequest>; +} diff --git a/ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs b/ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs new file mode 100644 index 000000000..a22ea7d09 --- /dev/null +++ b/ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Images.Commands +{ + public class SaveImageToDiskHandler : IRequestHandler> + { + private static readonly SHA1CryptoServiceProvider Crypto; + + static SaveImageToDiskHandler() => Crypto = new SHA1CryptoServiceProvider(); + + public async Task> Handle( + SaveImageToDisk request, + CancellationToken cancellationToken) + { + try + { + byte[] hash = Crypto.ComputeHash(request.Buffer); + string hex = BitConverter.ToString(hash).Replace("-", string.Empty); + + string fileName = Path.Combine(FileSystemLayout.ImageCacheFolder, hex); + + if (!Directory.Exists(FileSystemLayout.ImageCacheFolder)) + { + Directory.CreateDirectory(FileSystemLayout.ImageCacheFolder); + } + + await File.WriteAllBytesAsync(fileName, request.Buffer, cancellationToken); + return hex; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + } +} diff --git a/ErsatzTV.Application/Images/ImageViewModel.cs b/ErsatzTV.Application/Images/ImageViewModel.cs new file mode 100644 index 000000000..d9c0c0acc --- /dev/null +++ b/ErsatzTV.Application/Images/ImageViewModel.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV.Application.Images +{ + // ReSharper disable once SuggestBaseTypeForParameter + public record ImageViewModel(byte[] Contents, string MimeType); +} diff --git a/ErsatzTV.Application/Images/Queries/GetImageContents.cs b/ErsatzTV.Application/Images/Queries/GetImageContents.cs new file mode 100644 index 000000000..a1bc6c383 --- /dev/null +++ b/ErsatzTV.Application/Images/Queries/GetImageContents.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Images.Queries +{ + public record GetImageContents(string FileName) : IRequest>; +} diff --git a/ErsatzTV.Application/Images/Queries/GetImageContentsHandler.cs b/ErsatzTV.Application/Images/Queries/GetImageContentsHandler.cs new file mode 100644 index 000000000..6598c4dff --- /dev/null +++ b/ErsatzTV.Application/Images/Queries/GetImageContentsHandler.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; +using Microsoft.Extensions.Caching.Memory; +using Winista.Mime; + +namespace ErsatzTV.Application.Images.Queries +{ + public class GetImageContentsHandler : IRequestHandler> + { + private static readonly MimeTypes MimeTypes = new(); + private readonly IMemoryCache _memoryCache; + + public GetImageContentsHandler(IMemoryCache memoryCache) => _memoryCache = memoryCache; + + public async Task> Handle( + GetImageContents request, + CancellationToken cancellationToken) + { + try + { + return await _memoryCache.GetOrCreateAsync( + request.FileName, + async entry => + { + entry.SlidingExpiration = TimeSpan.FromHours(1); + + string fileName = Path.Combine(FileSystemLayout.ImageCacheFolder, request.FileName); + byte[] contents = await File.ReadAllBytesAsync(fileName, cancellationToken); + MimeType mimeType = MimeTypes.GetMimeType(contents); + return new ImageViewModel(contents, mimeType.Name); + }); + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollection.cs b/ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollection.cs new file mode 100644 index 000000000..11cff638e --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollection.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public record CreateSimpleMediaCollection + (string Name) : IRequest>; +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollectionHandler.cs new file mode 100644 index 000000000..8940f7103 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollectionHandler.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaCollections.Mapper; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public class CreateSimpleMediaCollectionHandler : IRequestHandler> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + + public CreateSimpleMediaCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) => + _mediaCollectionRepository = mediaCollectionRepository; + + public Task> Handle( + CreateSimpleMediaCollection request, + CancellationToken cancellationToken) => + Validate(request).MapT(PersistCollection).Bind(v => v.ToEitherAsync()); + + private Task PersistCollection(SimpleMediaCollection c) => + _mediaCollectionRepository.Add(c).Map(ProjectToViewModel); + + private Task> Validate(CreateSimpleMediaCollection request) => + ValidateName(request).MapT(name => new SimpleMediaCollection { Name = name }); + + private async Task> ValidateName(CreateSimpleMediaCollection createCollection) + { + List allNames = await _mediaCollectionRepository.GetSimpleMediaCollections() + .Map(list => list.Map(c => c.Name).ToList()); + + Validation result1 = createCollection.NotEmpty(c => c.Name) + .Bind(_ => createCollection.NotLongerThan(50)(c => c.Name)); + + var result2 = Optional(createCollection.Name) + .Filter(name => !allNames.Contains(name)) + .ToValidation("Media collection name must be unique"); + + return (result1, result2).Apply((_, _) => createCollection.Name); + } + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/DeleteSimpleMediaCollection.cs b/ErsatzTV.Application/MediaCollections/Commands/DeleteSimpleMediaCollection.cs new file mode 100644 index 000000000..0251bbcb9 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/DeleteSimpleMediaCollection.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public record DeleteSimpleMediaCollection(int SimpleMediaCollectionId) : IRequest>; +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/DeleteSimpleMediaCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/DeleteSimpleMediaCollectionHandler.cs new file mode 100644 index 000000000..0c7b405d9 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/DeleteSimpleMediaCollectionHandler.cs @@ -0,0 +1,34 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public class + DeleteSimpleMediaCollectionHandler : IRequestHandler> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + + public DeleteSimpleMediaCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) => + _mediaCollectionRepository = mediaCollectionRepository; + + public async Task> Handle( + DeleteSimpleMediaCollection request, + CancellationToken cancellationToken) => + (await SimpleMediaCollectionMustExist(request)) + .Map(DoDeletion) + .ToEither(); + + private Task DoDeletion(int mediaCollectionId) => _mediaCollectionRepository.Delete(mediaCollectionId); + + private async Task> SimpleMediaCollectionMustExist( + DeleteSimpleMediaCollection deleteMediaCollection) => + (await _mediaCollectionRepository.GetSimpleMediaCollection(deleteMediaCollection.SimpleMediaCollectionId)) + .ToValidation( + $"SimpleMediaCollection {deleteMediaCollection.SimpleMediaCollectionId} does not exist.") + .Map(c => c.Id); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItems.cs b/ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItems.cs new file mode 100644 index 000000000..84c4c14c4 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItems.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public record ReplaceSimpleMediaCollectionItems + (int MediaCollectionId, List MediaItemIds) : IRequest>>; +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItemsHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItemsHandler.cs new file mode 100644 index 000000000..5c7c79965 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItemsHandler.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using LanguageExt.UnsafeValueAccess; +using MediatR; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public class ReplaceSimpleMediaCollectionItemsHandler : IRequestHandler>> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + private readonly IMediaItemRepository _mediaItemRepository; + + public ReplaceSimpleMediaCollectionItemsHandler( + IMediaCollectionRepository mediaCollectionRepository, + IMediaItemRepository mediaItemRepository) + { + _mediaCollectionRepository = mediaCollectionRepository; + _mediaItemRepository = mediaItemRepository; + } + + public Task>> Handle( + ReplaceSimpleMediaCollectionItems request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(mediaItems => PersistItems(request, mediaItems)) + .Bind(v => v.ToEitherAsync()); + + private async Task> PersistItems( + ReplaceSimpleMediaCollectionItems request, + List mediaItems) + { + await _mediaCollectionRepository.ReplaceItems(request.MediaCollectionId, mediaItems); + return mediaItems.Map(MediaItems.Mapper.ProjectToViewModel).ToList(); + } + + private Task>> Validate(ReplaceSimpleMediaCollectionItems request) => + MediaCollectionMustExist(request).BindT(_ => MediaItemsMustExist(request)); + + private async Task> MediaCollectionMustExist( + ReplaceSimpleMediaCollectionItems request) => + (await _mediaCollectionRepository.GetSimpleMediaCollection(request.MediaCollectionId)) + .ToValidation("[MediaCollectionId] does not exist."); + + private async Task>> MediaItemsMustExist( + ReplaceSimpleMediaCollectionItems replaceItems) + { + var allMediaItems = (await replaceItems.MediaItemIds.Map(i => _mediaItemRepository.Get(i)).Sequence()) + .ToList(); + if (allMediaItems.Any(x => x.IsNone)) + { + return BaseError.New("[MediaItemId] does not exist"); + } + + return allMediaItems.Sequence().ValueUnsafe().ToList(); + } + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/UpdateChannelHandler.cs new file mode 100644 index 000000000..cab486acc --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/UpdateChannelHandler.cs @@ -0,0 +1,47 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public class + UpdateSimpleMediaCollectionHandler : MediatR.IRequestHandler> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + + public UpdateSimpleMediaCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) => + _mediaCollectionRepository = mediaCollectionRepository; + + public Task> Handle( + UpdateSimpleMediaCollection request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(c => ApplyUpdateRequest(c, request)) + .Bind(v => v.ToEitherAsync()); + + private async Task ApplyUpdateRequest(SimpleMediaCollection c, UpdateSimpleMediaCollection update) + { + c.Name = update.Name; + await _mediaCollectionRepository.Update(c); + return Unit.Default; + } + + private async Task> + Validate(UpdateSimpleMediaCollection request) => + (await SimpleMediaCollectionMustExist(request), ValidateName(request)) + .Apply((simpleMediaCollectionToUpdate, _) => simpleMediaCollectionToUpdate); + + private Task> SimpleMediaCollectionMustExist( + UpdateSimpleMediaCollection updateSimpleMediaCollection) => + _mediaCollectionRepository.GetSimpleMediaCollection(updateSimpleMediaCollection.MediaCollectionId) + .Map(v => v.ToValidation("SimpleMediaCollection does not exist.")); + + private Validation ValidateName(UpdateSimpleMediaCollection updateSimpleMediaCollection) => + updateSimpleMediaCollection.NotEmpty(c => c.Name) + .Bind(_ => updateSimpleMediaCollection.NotLongerThan(50)(c => c.Name)); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/UpdateSimpleMediaCollection.cs b/ErsatzTV.Application/MediaCollections/Commands/UpdateSimpleMediaCollection.cs new file mode 100644 index 000000000..55ceb9f41 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/UpdateSimpleMediaCollection.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public record UpdateSimpleMediaCollection + (int MediaCollectionId, string Name) : MediatR.IRequest>; +} diff --git a/ErsatzTV.Application/MediaCollections/Mapper.cs b/ErsatzTV.Application/MediaCollections/Mapper.cs new file mode 100644 index 000000000..021ae7fa0 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Mapper.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Core.AggregateModels; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.MediaCollections +{ + internal static class Mapper + { + internal static MediaCollectionViewModel ProjectToViewModel(MediaCollection mediaCollection) => + new(mediaCollection.Id, mediaCollection.Name); + + internal static MediaCollectionSummaryViewModel ProjectToViewModel( + MediaCollectionSummary mediaCollectionSummary) => + new( + mediaCollectionSummary.Id, + mediaCollectionSummary.Name, + mediaCollectionSummary.ItemCount, + mediaCollectionSummary.IsSimple); + } +} diff --git a/ErsatzTV.Application/MediaCollections/MediaCollectionSummaryViewModel.cs b/ErsatzTV.Application/MediaCollections/MediaCollectionSummaryViewModel.cs new file mode 100644 index 000000000..1a9d552f3 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/MediaCollectionSummaryViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.MediaCollections +{ + public record MediaCollectionSummaryViewModel(int Id, string Name, int ItemCount, bool IsSimple); +} diff --git a/ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs b/ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs new file mode 100644 index 000000000..add855bf0 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.MediaCollections +{ + public record MediaCollectionViewModel(int Id, string Name); +} diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetAllMediaCollections.cs b/ErsatzTV.Application/MediaCollections/Queries/GetAllMediaCollections.cs new file mode 100644 index 000000000..678d5a80a --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Queries/GetAllMediaCollections.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.MediaCollections.Queries +{ + public record GetAllMediaCollections : IRequest>; +} diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetAllMediaCollectionsHandler.cs b/ErsatzTV.Application/MediaCollections/Queries/GetAllMediaCollectionsHandler.cs new file mode 100644 index 000000000..5e2a68986 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Queries/GetAllMediaCollectionsHandler.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaCollections.Mapper; + +namespace ErsatzTV.Application.MediaCollections.Queries +{ + public class GetAllMediaCollectionsHandler : IRequestHandler> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + + public GetAllMediaCollectionsHandler(IMediaCollectionRepository mediaCollectionRepository) => + _mediaCollectionRepository = mediaCollectionRepository; + + public Task> Handle( + GetAllMediaCollections request, + CancellationToken cancellationToken) => + _mediaCollectionRepository.GetAll().Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetAllSimpleMediaCollections.cs b/ErsatzTV.Application/MediaCollections/Queries/GetAllSimpleMediaCollections.cs new file mode 100644 index 000000000..e8a9a87ff --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Queries/GetAllSimpleMediaCollections.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.MediaCollections.Queries +{ + public record GetAllSimpleMediaCollections : IRequest>; +} diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetAllSimpleMediaCollectionsHandler.cs b/ErsatzTV.Application/MediaCollections/Queries/GetAllSimpleMediaCollectionsHandler.cs new file mode 100644 index 000000000..40b85740a --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Queries/GetAllSimpleMediaCollectionsHandler.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using MediatR; +using static ErsatzTV.Application.MediaCollections.Mapper; + +namespace ErsatzTV.Application.MediaCollections.Queries +{ + public class + GetAllSimpleMediaCollectionsHandler : IRequestHandler> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + + public GetAllSimpleMediaCollectionsHandler(IMediaCollectionRepository mediaCollectionRepository) => + _mediaCollectionRepository = mediaCollectionRepository; + + public async Task> Handle( + GetAllSimpleMediaCollections request, + CancellationToken cancellationToken) => + (await _mediaCollectionRepository.GetSimpleMediaCollections()).Map(ProjectToViewModel).ToList(); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummaries.cs b/ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummaries.cs new file mode 100644 index 000000000..d48c716dc --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummaries.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.MediaCollections.Queries +{ + public record GetMediaCollectionSummaries(string SearchString) : IRequest>; +} diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummariesHandler.cs b/ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummariesHandler.cs new file mode 100644 index 000000000..b08800aeb --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummariesHandler.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaCollections.Mapper; + +namespace ErsatzTV.Application.MediaCollections.Queries +{ + public class + GetMediaCollectionSummariesHandler : IRequestHandler> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + + public GetMediaCollectionSummariesHandler(IMediaCollectionRepository mediaCollectionRepository) => + _mediaCollectionRepository = mediaCollectionRepository; + + public Task> Handle( + GetMediaCollectionSummaries request, + CancellationToken cancellationToken) => + _mediaCollectionRepository.GetSummaries(request.SearchString) + .Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionById.cs b/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionById.cs new file mode 100644 index 000000000..937e5e5c4 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaCollections.Queries +{ + public record GetSimpleMediaCollectionById(int Id) : IRequest>; +} diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionByIdHandler.cs b/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionByIdHandler.cs new file mode 100644 index 000000000..a3a1a070a --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionByIdHandler.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaCollections.Mapper; + +namespace ErsatzTV.Application.MediaCollections.Queries +{ + public class + GetSimpleMediaCollectionByIdHandler : IRequestHandler> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + + public GetSimpleMediaCollectionByIdHandler(IMediaCollectionRepository mediaCollectionRepository) => + _mediaCollectionRepository = mediaCollectionRepository; + + public Task> Handle( + GetSimpleMediaCollectionById request, + CancellationToken cancellationToken) => + _mediaCollectionRepository.GetSimpleMediaCollection(request.Id) + .MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionItems.cs b/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionItems.cs new file mode 100644 index 000000000..ed0f21f74 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionItems.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using ErsatzTV.Application.MediaItems; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaCollections.Queries +{ + public record GetSimpleMediaCollectionItems(int Id) : IRequest>>; +} diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionItemsHandler.cs b/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionItemsHandler.cs new file mode 100644 index 000000000..92e918fa6 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionItemsHandler.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaItems.Mapper; + +namespace ErsatzTV.Application.MediaCollections.Queries +{ + public class GetSimpleMediaCollectionItemsHandler : IRequestHandler>> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + + public GetSimpleMediaCollectionItemsHandler(IMediaCollectionRepository mediaCollectionRepository) => + _mediaCollectionRepository = mediaCollectionRepository; + + public Task>> Handle( + GetSimpleMediaCollectionItems request, + CancellationToken cancellationToken) => + _mediaCollectionRepository.GetSimpleMediaCollectionItems(request.Id) + .MapT(mediaItems => mediaItems.Map(ProjectToViewModel)); + } +} diff --git a/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs b/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs new file mode 100644 index 000000000..7d51ffa50 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.MediaItems +{ + public record AggregateMediaItemViewModel(string Source, string Title, int Count, string Duration); +} diff --git a/ErsatzTV.Application/MediaItems/Commands/CreateMediaItem.cs b/ErsatzTV.Application/MediaItems/Commands/CreateMediaItem.cs new file mode 100644 index 000000000..01252d9f5 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Commands/CreateMediaItem.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaItems.Commands +{ + public record CreateMediaItem(int MediaSourceId, string Path) : IRequest>; +} diff --git a/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs b/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs new file mode 100644 index 000000000..5002cfc7c --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs @@ -0,0 +1,96 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static LanguageExt.Prelude; +using static ErsatzTV.Application.MediaItems.Mapper; + +namespace ErsatzTV.Application.MediaItems.Commands +{ + public class CreateMediaItemHandler : IRequestHandler> + { + private readonly IConfigElementRepository _configElementRepository; + private readonly ILocalMetadataProvider _localMetadataProvider; + private readonly ILocalStatisticsProvider _localStatisticsProvider; + private readonly IMediaItemRepository _mediaItemRepository; + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly ISmartCollectionBuilder _smartCollectionBuilder; + + public CreateMediaItemHandler( + IMediaItemRepository mediaItemRepository, + IMediaSourceRepository mediaSourceRepository, + IConfigElementRepository configElementRepository, + ISmartCollectionBuilder smartCollectionBuilder, + ILocalMetadataProvider localMetadataProvider, + ILocalStatisticsProvider localStatisticsProvider) + { + _mediaItemRepository = mediaItemRepository; + _mediaSourceRepository = mediaSourceRepository; + _configElementRepository = configElementRepository; + _smartCollectionBuilder = smartCollectionBuilder; + _localMetadataProvider = localMetadataProvider; + _localStatisticsProvider = localStatisticsProvider; + } + + public Task> Handle( + CreateMediaItem request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(PersistMediaItem) + .Bind(v => v.ToEitherAsync()); + + private async Task PersistMediaItem(RequestParameters parameters) + { + await _mediaItemRepository.Add(parameters.MediaItem); + + await _localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem); + await _localMetadataProvider.RefreshMetadata(parameters.MediaItem); + await _smartCollectionBuilder.RefreshSmartCollections(parameters.MediaItem); + + return ProjectToViewModel(parameters.MediaItem); + } + + private async Task> Validate(CreateMediaItem request) => + (await ValidateMediaSource(request), PathMustExist(request), await ValidateFFprobePath()) + .Apply( + (mediaSourceId, path, ffprobePath) => new RequestParameters( + ffprobePath, + new MediaItem + { + MediaSourceId = mediaSourceId, + Path = path + })); + + private async Task> ValidateMediaSource(CreateMediaItem createMediaItem) => + (await MediaSourceMustExist(createMediaItem)).Bind(MediaSourceMustBeLocal); + + private async Task> MediaSourceMustExist(CreateMediaItem createMediaItem) => + (await _mediaSourceRepository.Get(createMediaItem.MediaSourceId)) + .ToValidation($"[MediaSource] {createMediaItem.MediaSourceId} does not exist."); + + private Validation MediaSourceMustBeLocal(MediaSource mediaSource) => + Some(mediaSource) + .Filter(ms => ms is LocalMediaSource) + .ToValidation($"[MediaSource] {mediaSource.Id} must be a local media source") + .Map(ms => ms.Id); + + private Validation PathMustExist(CreateMediaItem createMediaItem) => + Some(createMediaItem.Path) + .Filter(File.Exists) + .ToValidation("[Path] does not exist on the file system"); + + private Task> ValidateFFprobePath() => + _configElementRepository.GetValue(ConfigElementKey.FFprobePath) + .FilterT(File.Exists) + .Map( + ffprobePath => + ffprobePath.ToValidation("FFprobe path does not exist on the file system")); + + private record RequestParameters(string FFprobePath, MediaItem MediaItem); + } +} diff --git a/ErsatzTV.Application/MediaItems/Commands/DeleteMediaItem.cs b/ErsatzTV.Application/MediaItems/Commands/DeleteMediaItem.cs new file mode 100644 index 000000000..f1a1abd66 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Commands/DeleteMediaItem.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaItems.Commands +{ + public record DeleteMediaItem(int MediaItemId) : IRequest>; +} diff --git a/ErsatzTV.Application/MediaItems/Commands/DeleteMediaItemHandler.cs b/ErsatzTV.Application/MediaItems/Commands/DeleteMediaItemHandler.cs new file mode 100644 index 000000000..59357d6fa --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Commands/DeleteMediaItemHandler.cs @@ -0,0 +1,31 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaItems.Commands +{ + public class DeleteMediaItemHandler : IRequestHandler> + { + private readonly IMediaItemRepository _mediaItemRepository; + + public DeleteMediaItemHandler(IMediaItemRepository mediaItemRepository) => + _mediaItemRepository = mediaItemRepository; + + public async Task> Handle( + DeleteMediaItem request, + CancellationToken cancellationToken) => + (await MediaItemMustExist(request)) + .Map(DoDeletion) + .ToEither(); + + private Task DoDeletion(int mediaItemId) => _mediaItemRepository.Delete(mediaItemId); + + private async Task> MediaItemMustExist(DeleteMediaItem deleteMediaItem) => + (await _mediaItemRepository.Get(deleteMediaItem.MediaItemId)) + .ToValidation($"MediaItem {deleteMediaItem.MediaItemId} does not exist.") + .Map(c => c.Id); + } +} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItem.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItem.cs new file mode 100644 index 000000000..608fe29ac --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItem.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.MediaItems.Commands +{ + public record RefreshMediaItem(int MediaItemId) : MediatR.IRequest>, + IBackgroundServiceRequest; +} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollections.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollections.cs new file mode 100644 index 000000000..49ab14ef9 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollections.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Application.MediaItems.Commands +{ + public record RefreshMediaItemCollections : RefreshMediaItem + { + public RefreshMediaItemCollections(int mediaItemId) : base(mediaItemId) + { + } + } +} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollectionsHandler.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollectionsHandler.cs new file mode 100644 index 000000000..b7d78bb95 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollectionsHandler.cs @@ -0,0 +1,46 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.MediaItems.Commands +{ + public class + RefreshMediaItemCollectionsHandler : MediatR.IRequestHandler> + { + private readonly IMediaItemRepository _mediaItemRepository; + private readonly ISmartCollectionBuilder _smartCollectionBuilder; + + public RefreshMediaItemCollectionsHandler( + IMediaItemRepository mediaItemRepository, + ISmartCollectionBuilder smartCollectionBuilder) + { + _mediaItemRepository = mediaItemRepository; + _smartCollectionBuilder = smartCollectionBuilder; + } + + public Task> Handle( + RefreshMediaItemCollections request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(RefreshCollections) + .Bind(v => v.ToEitherAsync()); + + private Task> Validate(RefreshMediaItemCollections request) => + MediaItemMustExist(request); + + private Task> MediaItemMustExist( + RefreshMediaItemCollections refreshMediaItemCollections) => + _mediaItemRepository.Get(refreshMediaItemCollections.MediaItemId) + .Map( + maybeItem => maybeItem.ToValidation( + $"[MediaItem] {refreshMediaItemCollections.MediaItemId} does not exist.")); + + private Task RefreshCollections(MediaItem mediaItem) => + _smartCollectionBuilder.RefreshSmartCollections(mediaItem).ToUnit(); + } +} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadata.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadata.cs new file mode 100644 index 000000000..4c3c1bd95 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadata.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Application.MediaItems.Commands +{ + public record RefreshMediaItemMetadata : RefreshMediaItem + { + public RefreshMediaItemMetadata(int mediaItemId) : base(mediaItemId) + { + } + } +} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs new file mode 100644 index 000000000..9a4a9a0fd --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs @@ -0,0 +1,52 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.MediaItems.Commands +{ + public class + RefreshMediaItemMetadataHandler : MediatR.IRequestHandler> + { + private readonly ILocalMetadataProvider _localMetadataProvider; + private readonly IMediaItemRepository _mediaItemRepository; + + public RefreshMediaItemMetadataHandler( + IMediaItemRepository mediaItemRepository, + ILocalMetadataProvider localMetadataProvider) + { + _mediaItemRepository = mediaItemRepository; + _localMetadataProvider = localMetadataProvider; + } + + public Task> Handle( + RefreshMediaItemMetadata request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(RefreshMetadata) + .Bind(v => v.ToEitherAsync()); + + private Task> Validate(RefreshMediaItemMetadata request) => + MediaItemMustExist(request).BindT(PathMustExist); + + private Task> MediaItemMustExist( + RefreshMediaItemMetadata refreshMediaItemMetadata) => + _mediaItemRepository.Get(refreshMediaItemMetadata.MediaItemId) + .Map( + maybeItem => maybeItem.ToValidation( + $"[MediaItem] {refreshMediaItemMetadata.MediaItemId} does not exist.")); + + private Validation PathMustExist(MediaItem mediaItem) => + Some(mediaItem) + .Filter(item => File.Exists(item.Path)) + .ToValidation($"[Path] '{mediaItem.Path}' does not exist on the file system"); + + private Task RefreshMetadata(MediaItem mediaItem) => + _localMetadataProvider.RefreshMetadata(mediaItem).ToUnit(); + } +} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatistics.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatistics.cs new file mode 100644 index 000000000..f25ecec46 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatistics.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Application.MediaItems.Commands +{ + public record RefreshMediaItemStatistics : RefreshMediaItem + { + public RefreshMediaItemStatistics(int mediaItemId) : base(mediaItemId) + { + } + } +} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatisticsHandler.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatisticsHandler.cs new file mode 100644 index 000000000..7d9b1c758 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatisticsHandler.cs @@ -0,0 +1,65 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.MediaItems.Commands +{ + public class + RefreshMediaItemStatisticsHandler : MediatR.IRequestHandler> + { + private readonly IConfigElementRepository _configElementRepository; + private readonly ILocalStatisticsProvider _localStatisticsProvider; + private readonly IMediaItemRepository _mediaItemRepository; + + public RefreshMediaItemStatisticsHandler( + IMediaItemRepository mediaItemRepository, + IConfigElementRepository configElementRepository, + ILocalStatisticsProvider localStatisticsProvider) + { + _mediaItemRepository = mediaItemRepository; + _configElementRepository = configElementRepository; + _localStatisticsProvider = localStatisticsProvider; + } + + public Task> Handle( + RefreshMediaItemStatistics request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(RefreshStatistics) + .Bind(v => v.ToEitherAsync()); + + private async Task> Validate(RefreshMediaItemStatistics request) => + (await MediaItemMustExist(request).BindT(PathMustExist), await ValidateFFprobePath()) + .Apply((mediaItem, ffprobePath) => new RefreshParameters(mediaItem, ffprobePath)); + + private Task> MediaItemMustExist( + RefreshMediaItemStatistics refreshMediaItemStatistics) => + _mediaItemRepository.Get(refreshMediaItemStatistics.MediaItemId) + .Map( + maybeItem => maybeItem.ToValidation( + $"[MediaItem] {refreshMediaItemStatistics.MediaItemId} does not exist.")); + + private Validation PathMustExist(MediaItem mediaItem) => + Some(mediaItem) + .Filter(item => File.Exists(item.Path)) + .ToValidation($"[Path] '{mediaItem.Path}' does not exist on the file system"); + + private Task> ValidateFFprobePath() => + _configElementRepository.GetValue(ConfigElementKey.FFprobePath) + .FilterT(File.Exists) + .Map( + ffprobePath => + ffprobePath.ToValidation("FFprobe path does not exist on the file system")); + + private Task RefreshStatistics(RefreshParameters parameters) => + _localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem).ToUnit(); + + private record RefreshParameters(MediaItem MediaItem, string FFprobePath); + } +} diff --git a/ErsatzTV.Application/MediaItems/Mapper.cs b/ErsatzTV.Application/MediaItems/Mapper.cs new file mode 100644 index 000000000..fdac17817 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Mapper.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.MediaItems +{ + internal static class Mapper + { + internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) => + new( + mediaItem.Id, + mediaItem.MediaSourceId, + mediaItem.Path); + } +} diff --git a/ErsatzTV.Application/MediaItems/MediaItemViewModel.cs b/ErsatzTV.Application/MediaItems/MediaItemViewModel.cs new file mode 100644 index 000000000..108127c84 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/MediaItemViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.MediaItems +{ + public record MediaItemViewModel(int Id, int MediaSourceId, string Path); +} diff --git a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs new file mode 100644 index 000000000..cfd1f2dc8 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using ErsatzTV.Core.Domain; +using MediatR; + +namespace ErsatzTV.Application.MediaItems.Queries +{ + public record GetAggregateMediaItems + (MediaType MediaType, string SearchString) : IRequest>; +} diff --git a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs new file mode 100644 index 000000000..60fe58b6a --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using MediatR; + +namespace ErsatzTV.Application.MediaItems.Queries +{ + public class + GetAggregateMediaItemsHandler : IRequestHandler> + { + private readonly IMediaItemRepository _mediaItemRepository; + + public GetAggregateMediaItemsHandler(IMediaItemRepository mediaItemRepository) => + _mediaItemRepository = mediaItemRepository; + + public async Task> Handle( + GetAggregateMediaItems request, + CancellationToken cancellationToken) + { + IEnumerable allItems = await _mediaItemRepository.GetAll(request.MediaType); + + if (!string.IsNullOrEmpty(request.SearchString)) + { + allItems = allItems.Filter(i => i.Metadata?.Title.Contains(request.SearchString) == true); + } + + return allItems.GroupBy(c => new { c.Source.Name, c.Metadata.Title }).Map( + group => new AggregateMediaItemViewModel( + group.Key.Name, + group.Key.Title, + group.Count(), + group.Count() == 1 ? DisplayDuration(group.Head()) : string.Empty)) + .ToList(); + } + + private static string DisplayDuration(MediaItem mediaItem) => string.Format( + mediaItem.Metadata?.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}", + mediaItem.Metadata?.Duration); + } +} diff --git a/ErsatzTV.Application/MediaItems/Queries/GetAllMediaItems.cs b/ErsatzTV.Application/MediaItems/Queries/GetAllMediaItems.cs new file mode 100644 index 000000000..cf06145aa --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Queries/GetAllMediaItems.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.MediaItems.Queries +{ + public record GetAllMediaItems : IRequest>; +} diff --git a/ErsatzTV.Application/MediaItems/Queries/GetAllMediaItemsHandler.cs b/ErsatzTV.Application/MediaItems/Queries/GetAllMediaItemsHandler.cs new file mode 100644 index 000000000..2806e91d0 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Queries/GetAllMediaItemsHandler.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using MediatR; +using static ErsatzTV.Application.MediaItems.Mapper; + +namespace ErsatzTV.Application.MediaItems.Queries +{ + public class GetAllMediaItemsHandler : IRequestHandler> + { + private readonly IMediaItemRepository _mediaItemRepository; + + public GetAllMediaItemsHandler(IMediaItemRepository mediaItemRepository) => + _mediaItemRepository = mediaItemRepository; + + public async Task> Handle( + GetAllMediaItems request, + CancellationToken cancellationToken) => + (await _mediaItemRepository.GetAll()).Map(ProjectToViewModel).ToList(); + } +} diff --git a/ErsatzTV.Application/MediaItems/Queries/GetMediaItemById.cs b/ErsatzTV.Application/MediaItems/Queries/GetMediaItemById.cs new file mode 100644 index 000000000..8586d72f5 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Queries/GetMediaItemById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaItems.Queries +{ + public record GetMediaItemById(int Id) : IRequest>; +} diff --git a/ErsatzTV.Application/MediaItems/Queries/GetMediaItemByIdHandler.cs b/ErsatzTV.Application/MediaItems/Queries/GetMediaItemByIdHandler.cs new file mode 100644 index 000000000..279159075 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Queries/GetMediaItemByIdHandler.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaItems.Mapper; + +namespace ErsatzTV.Application.MediaItems.Queries +{ + public class GetMediaItemByIdHandler : IRequestHandler> + { + private readonly IMediaItemRepository _mediaItemRepository; + + public GetMediaItemByIdHandler(IMediaItemRepository mediaItemRepository) => + _mediaItemRepository = mediaItemRepository; + + public Task> Handle( + GetMediaItemById request, + CancellationToken cancellationToken) => + _mediaItemRepository.Get(request.Id) + .MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSource.cs b/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSource.cs new file mode 100644 index 000000000..49bb842d0 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSource.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public record CreateLocalMediaSource + (string Name, MediaType MediaType, string Folder) : IRequest>; +} diff --git a/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs b/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs new file mode 100644 index 000000000..6b093f844 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaSources.Mapper; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public class CreateLocalMediaSourceHandler : IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public CreateLocalMediaSourceHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public Task> Handle( + CreateLocalMediaSource request, + CancellationToken cancellationToken) => + Validate(request).MapT(PersistLocalMediaSource).Bind(v => v.ToEitherAsync()); + + private Task PersistLocalMediaSource(LocalMediaSource c) => + _mediaSourceRepository.Add(c).Map(ProjectToViewModel); + + private async Task> Validate(CreateLocalMediaSource request) => + (await ValidateName(request), await ValidateFolder(request)) + .Apply( + (name, folder) => + new LocalMediaSource + { + Name = name, + MediaType = request.MediaType, + Folder = folder + }); + + private async Task> ValidateName(CreateLocalMediaSource createCollection) + { + List allNames = await _mediaSourceRepository.GetAll() + .Map(list => list.Map(c => c.Name).ToList()); + + Validation result1 = createCollection.NotEmpty(c => c.Name) + .Bind(_ => createCollection.NotLongerThan(50)(c => c.Name)); + + var result2 = Optional(createCollection.Name) + .Filter(name => !allNames.Contains(name)) + .ToValidation("Media source name must be unique"); + + return (result1, result2).Apply((_, _) => createCollection.Name); + } + + private async Task> ValidateFolder(CreateLocalMediaSource createCollection) + { + List allFolders = await _mediaSourceRepository.GetAll() + .Map(list => list.OfType().Map(c => c.Folder).ToList()); + + + return Optional(createCollection.Folder) + .Filter(folder => allFolders.ForAll(f => !AreSubPaths(f, folder))) + .ToValidation("Folder must not belong to another media source"); + } + + private static bool AreSubPaths(string path1, string path2) + { + string one = path1 + Path.DirectorySeparatorChar; + string two = path2 + Path.DirectorySeparatorChar; + return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) || + two.StartsWith(one, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSource.cs b/ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSource.cs new file mode 100644 index 000000000..8455a4c63 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSource.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public record DeleteLocalMediaSource(int LocalMediaSourceId) : IRequest>; +} diff --git a/ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSourceHandler.cs b/ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSourceHandler.cs new file mode 100644 index 000000000..8e9fd604e --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSourceHandler.cs @@ -0,0 +1,50 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public class + DeleteLocalMediaSourceHandler : IRequestHandler> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + private readonly IMediaSourceRepository _mediaSourceRepository; + + public DeleteLocalMediaSourceHandler( + IMediaSourceRepository mediaSourceRepository, + IMediaCollectionRepository mediaCollectionRepository) + { + _mediaSourceRepository = mediaSourceRepository; + _mediaCollectionRepository = mediaCollectionRepository; + } + + public async Task> Handle( + DeleteLocalMediaSource request, + CancellationToken cancellationToken) => + (await MediaSourceMustExist(request)) + .Map(DoDeletion) + .ToEither(); + + private async Task DoDeletion(LocalMediaSource mediaSource) + { + await _mediaSourceRepository.Delete(mediaSource.Id); + if (mediaSource.MediaType == MediaType.TvShow) + { + await _mediaCollectionRepository.DeleteEmptyTelevisionCollections(); + } + } + + private async Task> MediaSourceMustExist( + DeleteLocalMediaSource deleteMediaSource) => + (await _mediaSourceRepository.Get(deleteMediaSource.LocalMediaSourceId)) + .OfType() + .HeadOrNone() + .ToValidation( + $"Local media source {deleteMediaSource.LocalMediaSourceId} does not exist."); + } +} diff --git a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs new file mode 100644 index 000000000..0e08d61da --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public record ScanLocalMediaSource(int MediaSourceId) : IRequest>, + IBackgroundServiceRequest; +} diff --git a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs new file mode 100644 index 000000000..76134bf01 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs @@ -0,0 +1,56 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public class ScanLocalMediaSourceHandler : IRequestHandler> + { + private readonly IConfigElementRepository _configElementRepository; + private readonly ILocalMediaScanner _localMediaScanner; + private readonly IMediaSourceRepository _mediaSourceRepository; + + public ScanLocalMediaSourceHandler( + IMediaSourceRepository mediaSourceRepository, + IConfigElementRepository configElementRepository, + ILocalMediaScanner localMediaScanner) + { + _mediaSourceRepository = mediaSourceRepository; + _configElementRepository = configElementRepository; + _localMediaScanner = localMediaScanner; + } + + public Task> + Handle(ScanLocalMediaSource request, CancellationToken cancellationToken) => + Validate(request) + .MapT( + p => _localMediaScanner.ScanLocalMediaSource(p.LocalMediaSource, p.FFprobePath) + .Map(_ => p.LocalMediaSource.Name)) + .Bind(v => v.ToEitherAsync()); + + private async Task> Validate(ScanLocalMediaSource request) => + (await LocalMediaSourceMustExist(request), await ValidateFFprobePath()) + .Apply((localMediaSource, ffprobePath) => new RequestParameters(localMediaSource, ffprobePath)); + + private Task> LocalMediaSourceMustExist( + ScanLocalMediaSource request) => + _mediaSourceRepository.Get(request.MediaSourceId) + .Map(maybeMediaSource => maybeMediaSource.Map(ms => ms as LocalMediaSource)) + .Map(v => v.ToValidation($"Local media source {request.MediaSourceId} does not exist.")); + + private Task> ValidateFFprobePath() => + _configElementRepository.GetValue(ConfigElementKey.FFprobePath) + .FilterT(File.Exists) + .Map( + ffprobePath => + ffprobePath.ToValidation("FFprobe path does not exist on the file system")); + + private record RequestParameters(LocalMediaSource LocalMediaSource, string FFprobePath); + } +} diff --git a/ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlow.cs b/ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlow.cs new file mode 100644 index 000000000..1aa6da317 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlow.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public record StartPlexPinFlow : IRequest>; +} diff --git a/ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlowHandler.cs b/ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlowHandler.cs new file mode 100644 index 000000000..7a3cf2967 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlowHandler.cs @@ -0,0 +1,38 @@ +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Plex; +using LanguageExt; +using MediatR; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public class StartPlexPinFlowHandler : IRequestHandler> + { + private readonly ChannelWriter _channel; + private readonly IPlexTvApiClient _plexTvApiClient; + + public StartPlexPinFlowHandler( + IPlexTvApiClient plexTvApiClient, + ChannelWriter channel) + { + _plexTvApiClient = plexTvApiClient; + _channel = channel; + } + + public Task> Handle( + StartPlexPinFlow request, + CancellationToken cancellationToken) => + _plexTvApiClient.StartPinFlow().Bind( + result => result.Match( + Left: error => Task.FromResult(Left(error)), + Right: async pin => + { + await _channel.WriteAsync(new TryCompletePlexPinFlow(pin), cancellationToken); + return Right(pin.Url); + }) + ); + } +} diff --git a/ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibraries.cs b/ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibraries.cs new file mode 100644 index 000000000..38aba8f55 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibraries.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public record SynchronizePlexLibraries(int PlexMediaSourceId) : MediatR.IRequest>; +} diff --git a/ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibrariesHandler.cs b/ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibrariesHandler.cs new file mode 100644 index 000000000..9d284103d --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibrariesHandler.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Plex; +using LanguageExt; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public class + SynchronizePlexLibrariesHandler : MediatR.IRequestHandler> + { + private readonly ILogger _logger; + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IPlexSecretStore _plexSecretStore; + private readonly IPlexServerApiClient _plexServerApiClient; + + public SynchronizePlexLibrariesHandler( + IMediaSourceRepository mediaSourceRepository, + IPlexSecretStore plexSecretStore, + IPlexServerApiClient plexServerApiClient, + ILogger logger) + { + _mediaSourceRepository = mediaSourceRepository; + _plexSecretStore = plexSecretStore; + _plexServerApiClient = plexServerApiClient; + _logger = logger; + } + + public Task> Handle( + SynchronizePlexLibraries request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(SynchronizeLibraries) + .Bind(v => v.ToEitherAsync()); + + private Task> Validate(SynchronizePlexLibraries request) => + MediaSourceMustExist(request) + .BindT(MediaSourceMustHaveActiveConnection) + .BindT(MediaSourceMustHaveToken); + + private Task> MediaSourceMustExist(SynchronizePlexLibraries request) => + _mediaSourceRepository.GetPlex(request.PlexMediaSourceId) + .Map(o => o.ToValidation("Plex media source does not exist.")); + + private Validation MediaSourceMustHaveActiveConnection( + PlexMediaSource plexMediaSource) + { + Option maybeConnection = + plexMediaSource.Connections.SingleOrDefault(c => c.IsActive); + return maybeConnection.Map(connection => new ConnectionParameters(plexMediaSource, connection)) + .ToValidation("Plex media source requires an active connection"); + } + + private async Task> MediaSourceMustHaveToken( + ConnectionParameters connectionParameters) + { + Option maybeToken = await + _plexSecretStore.GetServerAuthToken(connectionParameters.PlexMediaSource.ClientIdentifier); + return maybeToken.Map(token => connectionParameters with { PlexServerAuthToken = token }) + .ToValidation("Plex media source requires a token"); + } + + private async Task SynchronizeLibraries(ConnectionParameters connectionParameters) + { + Either> maybeLibraries = await _plexServerApiClient.GetLibraries( + connectionParameters.ActiveConnection, + connectionParameters.PlexServerAuthToken); + + await maybeLibraries.Match( + libraries => + { + List existing = connectionParameters.PlexMediaSource.Libraries; + var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList(); + var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList(); + existing.AddRange(toAdd); + toRemove.ForEach(c => existing.Remove(c)); + + return _mediaSourceRepository.Update(connectionParameters.PlexMediaSource); + }, + error => + { + _logger.LogWarning( + "Unable to synchronize libraries from Plex server {PlexServer}: {Error}", + connectionParameters.PlexMediaSource.Name, + error.Value); + + return Task.CompletedTask; + }); + + return Unit.Default; + } + + private record ConnectionParameters( + PlexMediaSource PlexMediaSource, + PlexMediaSourceConnection ActiveConnection) + { + public PlexServerAuthToken PlexServerAuthToken { get; set; } + } + } +} diff --git a/ErsatzTV.Application/MediaSources/Commands/SynchronizePlexMediaSources.cs b/ErsatzTV.Application/MediaSources/Commands/SynchronizePlexMediaSources.cs new file mode 100644 index 000000000..55d1bab42 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/SynchronizePlexMediaSources.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public record + SynchronizePlexMediaSources : IRequest>>, IPlexBackgroundServiceRequest; +} diff --git a/ErsatzTV.Application/MediaSources/Commands/SynchronizePlexMediaSourcesHandler.cs b/ErsatzTV.Application/MediaSources/Commands/SynchronizePlexMediaSourcesHandler.cs new file mode 100644 index 000000000..9cd53fe14 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/SynchronizePlexMediaSourcesHandler.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public class + SynchronizePlexMediaSourcesHandler : IRequestHandler>> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IPlexTvApiClient _plexTvApiClient; + + public SynchronizePlexMediaSourcesHandler( + IMediaSourceRepository mediaSourceRepository, + IPlexTvApiClient plexTvApiClient) + { + _mediaSourceRepository = mediaSourceRepository; + _plexTvApiClient = plexTvApiClient; + } + + public Task>> Handle( + SynchronizePlexMediaSources request, + CancellationToken cancellationToken) => _plexTvApiClient.GetServers().BindAsync(SynchronizeAllServers); + + private async Task>> SynchronizeAllServers( + List servers) + { + List allExisting = await _mediaSourceRepository.GetAllPlex(); + foreach (PlexMediaSource server in servers) + { + await SynchronizeServer(allExisting, server); + } + + return allExisting; + } + + private async Task SynchronizeServer(List allExisting, PlexMediaSource server) + { + Option maybeExisting = + allExisting.Find(s => s.ClientIdentifier == server.ClientIdentifier); + await maybeExisting.Match( + existing => + { + existing.ProductVersion = server.ProductVersion; + existing.Name = server.Name; + MergeConnections(existing.Connections, server.Connections); + if (existing.Connections.Any() && existing.Connections.All(c => !c.IsActive)) + { + existing.Connections.Head().IsActive = true; + } + + return _mediaSourceRepository.Update(existing); + }, + async () => + { + await _mediaSourceRepository.Add(server); + if (server.Connections.Any()) + { + server.Connections.Head().IsActive = true; + } + + await _mediaSourceRepository.Update(server); + }); + } + + private void MergeConnections( + List existing, + List incoming) + { + var toAdd = incoming.Filter(connection => existing.All(c => c.Uri != connection.Uri)).ToList(); + var toRemove = existing.Filter(connection => incoming.All(c => c.Uri != connection.Uri)).ToList(); + existing.AddRange(toAdd); + toRemove.ForEach(c => existing.Remove(c)); + } + } +} diff --git a/ErsatzTV.Application/MediaSources/Commands/TryCompletePlexPinFlow.cs b/ErsatzTV.Application/MediaSources/Commands/TryCompletePlexPinFlow.cs new file mode 100644 index 000000000..e6d16ea40 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/TryCompletePlexPinFlow.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Plex; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public record TryCompletePlexPinFlow(PlexAuthPin AuthPin) : IRequest>, + IPlexBackgroundServiceRequest; +} diff --git a/ErsatzTV.Application/MediaSources/Commands/TryCompletePlexPinFlowHandler.cs b/ErsatzTV.Application/MediaSources/Commands/TryCompletePlexPinFlowHandler.cs new file mode 100644 index 000000000..3bdadd988 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Commands/TryCompletePlexPinFlowHandler.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Plex; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Commands +{ + public class TryCompletePlexPinFlowHandler : IRequestHandler> + { + private readonly ChannelWriter _channel; + private readonly IPlexTvApiClient _plexTvApiClient; + + public TryCompletePlexPinFlowHandler( + IPlexTvApiClient plexTvApiClient, + ChannelWriter channel) + { + _plexTvApiClient = plexTvApiClient; + _channel = channel; + } + + public async Task> + Handle(TryCompletePlexPinFlow request, CancellationToken cancellationToken) + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + CancellationToken token = cts.Token; + while (!token.IsCancellationRequested) + { + bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin); + if (result) + { + await _channel.WriteAsync(new SynchronizePlexMediaSources(), cancellationToken); + return true; + } + + await Task.Delay(TimeSpan.FromSeconds(1), token); + } + + return false; + } + } +} diff --git a/ErsatzTV.Application/MediaSources/LocalMediaSourceViewModel.cs b/ErsatzTV.Application/MediaSources/LocalMediaSourceViewModel.cs new file mode 100644 index 000000000..ad16412a4 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/LocalMediaSourceViewModel.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.MediaSources +{ + public record LocalMediaSourceViewModel(int Id, string Name, string Folder) + : MediaSourceViewModel(Id, Name, MediaSourceType.Local); +} diff --git a/ErsatzTV.Application/MediaSources/Mapper.cs b/ErsatzTV.Application/MediaSources/Mapper.cs new file mode 100644 index 000000000..3d1ffc26f --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Mapper.cs @@ -0,0 +1,22 @@ +using System.Linq; +using ErsatzTV.Core.Domain; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.MediaSources +{ + internal static class Mapper + { + internal static MediaSourceViewModel ProjectToViewModel(MediaSource mediaSource) => + mediaSource switch + { + LocalMediaSource lms => new LocalMediaSourceViewModel(lms.Id, lms.Name, lms.Folder), + PlexMediaSource pms => ProjectToViewModel(pms) + }; + + internal static PlexMediaSourceViewModel ProjectToViewModel(PlexMediaSource plexMediaSource) => + new( + plexMediaSource.Id, + plexMediaSource.Name, + Optional(plexMediaSource.Connections.SingleOrDefault(c => c.IsActive)).Match(c => c.Uri, string.Empty)); + } +} diff --git a/ErsatzTV.Application/MediaSources/MediaSourceViewModel.cs b/ErsatzTV.Application/MediaSources/MediaSourceViewModel.cs new file mode 100644 index 000000000..ab44f6947 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/MediaSourceViewModel.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.MediaSources +{ + public record MediaSourceViewModel(int Id, string Name, MediaSourceType SourceType); +} diff --git a/ErsatzTV.Application/MediaSources/PlexMediaSourceViewModel.cs b/ErsatzTV.Application/MediaSources/PlexMediaSourceViewModel.cs new file mode 100644 index 000000000..aa952a24e --- /dev/null +++ b/ErsatzTV.Application/MediaSources/PlexMediaSourceViewModel.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.MediaSources +{ + public record PlexMediaSourceViewModel(int Id, string Name, string Address) : MediaSourceViewModel( + Id, + Name, + MediaSourceType.Plex); +} diff --git a/ErsatzTV.Application/MediaSources/Queries/CountMediaItemsById.cs b/ErsatzTV.Application/MediaSources/Queries/CountMediaItemsById.cs new file mode 100644 index 000000000..b2432ca47 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Queries/CountMediaItemsById.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Queries +{ + public record CountMediaItemsById(int MediaSourceId) : IRequest; +} diff --git a/ErsatzTV.Application/MediaSources/Queries/CountMediaItemsByIdHandler.cs b/ErsatzTV.Application/MediaSources/Queries/CountMediaItemsByIdHandler.cs new file mode 100644 index 000000000..9b1c60e98 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Queries/CountMediaItemsByIdHandler.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Queries +{ + public class CountMediaItemsByIdHandler : IRequestHandler + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public CountMediaItemsByIdHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public Task Handle(CountMediaItemsById request, CancellationToken cancellationToken) => + _mediaSourceRepository.CountMediaItems(request.MediaSourceId); + } +} diff --git a/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSources.cs b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSources.cs new file mode 100644 index 000000000..fbf68473c --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSources.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Queries +{ + public record GetAllMediaSources : IRequest>; +} diff --git a/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesHandler.cs b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesHandler.cs new file mode 100644 index 000000000..27cb1e255 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesHandler.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using MediatR; +using static ErsatzTV.Application.MediaSources.Mapper; + +namespace ErsatzTV.Application.MediaSources.Queries +{ + public class GetAllMediaSourcesHandler : IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public GetAllMediaSourcesHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public async Task> Handle( + GetAllMediaSources request, + CancellationToken cancellationToken) => + (await _mediaSourceRepository.GetAll()).Map(ProjectToViewModel).ToList(); + } +} diff --git a/ErsatzTV.Application/MediaSources/Queries/GetAllPlexMediaSources.cs b/ErsatzTV.Application/MediaSources/Queries/GetAllPlexMediaSources.cs new file mode 100644 index 000000000..741332396 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Queries/GetAllPlexMediaSources.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Queries +{ + public record GetAllPlexMediaSources : IRequest>; +} diff --git a/ErsatzTV.Application/MediaSources/Queries/GetAllPlexMediaSourcesHandler.cs b/ErsatzTV.Application/MediaSources/Queries/GetAllPlexMediaSourcesHandler.cs new file mode 100644 index 000000000..f124bdb29 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Queries/GetAllPlexMediaSourcesHandler.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaSources.Mapper; + +namespace ErsatzTV.Application.MediaSources.Queries +{ + public class GetAllPlexMediaSourcesHandler : IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public GetAllPlexMediaSourcesHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public Task> Handle( + GetAllPlexMediaSources request, + CancellationToken cancellationToken) => + _mediaSourceRepository.GetAllPlex().Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/MediaSources/Queries/GetMediaSourceById.cs b/ErsatzTV.Application/MediaSources/Queries/GetMediaSourceById.cs new file mode 100644 index 000000000..2e96d3d33 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Queries/GetMediaSourceById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaSources.Queries +{ + public record GetMediaSourceById(int Id) : IRequest>; +} diff --git a/ErsatzTV.Application/MediaSources/Queries/GetMediaSourceByIdHandler.cs b/ErsatzTV.Application/MediaSources/Queries/GetMediaSourceByIdHandler.cs new file mode 100644 index 000000000..97374a020 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Queries/GetMediaSourceByIdHandler.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaSources.Mapper; + +namespace ErsatzTV.Application.MediaSources.Queries +{ + public class GetMediaSourceByIdHandler : IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public GetMediaSourceByIdHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public Task> Handle( + GetMediaSourceById request, + CancellationToken cancellationToken) => + _mediaSourceRepository.Get(request.Id) + .MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/Playouts/Commands/BuildPlayout.cs b/ErsatzTV.Application/Playouts/Commands/BuildPlayout.cs new file mode 100644 index 000000000..014271147 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Commands/BuildPlayout.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.Playouts.Commands +{ + public record BuildPlayout(int PlayoutId, bool Rebuild = false) : MediatR.IRequest>, + IBackgroundServiceRequest; +} diff --git a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs new file mode 100644 index 000000000..72d3a9609 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs @@ -0,0 +1,42 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Scheduling; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.Playouts.Commands +{ + public class BuildPlayoutHandler : MediatR.IRequestHandler> + { + private readonly IPlayoutBuilder _playoutBuilder; + private readonly IPlayoutRepository _playoutRepository; + + public BuildPlayoutHandler(IPlayoutRepository playoutRepository, IPlayoutBuilder playoutBuilder) + { + _playoutRepository = playoutRepository; + _playoutBuilder = playoutBuilder; + } + + public Task> Handle(BuildPlayout request, CancellationToken cancellationToken) => + Validate(request) + .Map(v => v.ToEither()) + .BindT(playout => ApplyUpdateRequest(request, playout)); + + private async Task> ApplyUpdateRequest(BuildPlayout request, Playout playout) + { + Playout result = await _playoutBuilder.BuildPlayoutItems(playout, request.Rebuild); + await _playoutRepository.Update(result); + return unit; + } + + private Task> Validate(BuildPlayout request) => + PlayoutMustExist(request); + + private async Task> PlayoutMustExist(BuildPlayout buildPlayout) => + (await _playoutRepository.GetFull(buildPlayout.PlayoutId)) + .ToValidation("Playout does not exist."); + } +} diff --git a/ErsatzTV.Application/Playouts/Commands/CreatePlayout.cs b/ErsatzTV.Application/Playouts/Commands/CreatePlayout.cs new file mode 100644 index 000000000..6c5ef28d9 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Commands/CreatePlayout.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Playouts.Commands +{ + public record CreatePlayout( + int ChannelId, + int ProgramScheduleId, + ProgramSchedulePlayoutType ProgramSchedulePlayoutType) : IRequest>; +} diff --git a/ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs new file mode 100644 index 000000000..5d996e8fe --- /dev/null +++ b/ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs @@ -0,0 +1,73 @@ +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Playouts.Mapper; +using static LanguageExt.Prelude; +using Channel = ErsatzTV.Core.Domain.Channel; + +namespace ErsatzTV.Application.Playouts.Commands +{ + public class + CreatePlayoutHandler : IRequestHandler> + { + private readonly ChannelWriter _channel; + private readonly IChannelRepository _channelRepository; + private readonly IPlayoutRepository _playoutRepository; + private readonly IProgramScheduleRepository _programScheduleRepository; + + public CreatePlayoutHandler( + IPlayoutRepository playoutRepository, + IChannelRepository channelRepository, + IProgramScheduleRepository programScheduleRepository, + ChannelWriter channel) + { + _playoutRepository = playoutRepository; + _channelRepository = channelRepository; + _programScheduleRepository = programScheduleRepository; + _channel = channel; + } + + public Task> Handle( + CreatePlayout request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(PersistPlayout) + .Bind(v => v.ToEitherAsync()); + + private async Task PersistPlayout(Playout c) + { + PlayoutViewModel result = await _playoutRepository.Add(c).Map(ProjectToViewModel); + await _channel.WriteAsync(new BuildPlayout(result.Id)); + return result; + } + + private async Task> Validate(CreatePlayout request) => + (await ChannelMustExist(request), await ProgramScheduleMustExist(request), ValidatePlayoutType(request)) + .Apply( + (channel, programSchedule, playoutType) => new Playout + { + ChannelId = channel.Id, + ProgramScheduleId = programSchedule.Id, + ProgramSchedulePlayoutType = playoutType + }); + + private async Task> ChannelMustExist(CreatePlayout createPlayout) => + (await _channelRepository.Get(createPlayout.ChannelId)) + .ToValidation("Channel does not exist."); + + private async Task> ProgramScheduleMustExist( + CreatePlayout createPlayout) => + (await _programScheduleRepository.Get(createPlayout.ProgramScheduleId)) + .ToValidation("ProgramSchedule does not exist."); + + private Validation ValidatePlayoutType(CreatePlayout createPlayout) => + Optional(createPlayout.ProgramSchedulePlayoutType) + .Filter(playoutType => playoutType != ProgramSchedulePlayoutType.None) + .ToValidation("[ProgramSchedulePlayoutType] must not be None"); + } +} diff --git a/ErsatzTV.Application/Playouts/Commands/DeletePlayout.cs b/ErsatzTV.Application/Playouts/Commands/DeletePlayout.cs new file mode 100644 index 000000000..8469ece7b --- /dev/null +++ b/ErsatzTV.Application/Playouts/Commands/DeletePlayout.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Playouts.Commands +{ + public record DeletePlayout(int PlayoutId) : IRequest>; +} diff --git a/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs new file mode 100644 index 000000000..d9d3df9d5 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Playouts.Commands +{ + public class DeletePlayoutHandler : IRequestHandler> + { + private readonly IPlayoutRepository _playoutRepository; + + public DeletePlayoutHandler(IPlayoutRepository playoutRepository) => + _playoutRepository = playoutRepository; + + public async Task> Handle( + DeletePlayout request, + CancellationToken cancellationToken) => + (await PlayoutMustExist(request)) + .Map(DoDeletion) + .ToEither(); + + private Task DoDeletion(int playoutId) => _playoutRepository.Delete(playoutId); + + private async Task> PlayoutMustExist( + DeletePlayout deletePlayout) => + (await _playoutRepository.Get(deletePlayout.PlayoutId)) + .ToValidation($"Playout {deletePlayout.PlayoutId} does not exist.") + .Map(c => c.Id); + } +} diff --git a/ErsatzTV.Application/Playouts/Commands/UpdatePlayout.cs b/ErsatzTV.Application/Playouts/Commands/UpdatePlayout.cs new file mode 100644 index 000000000..d4df2174e --- /dev/null +++ b/ErsatzTV.Application/Playouts/Commands/UpdatePlayout.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Playouts.Commands +{ + public record UpdatePlayout( + int PlayoutId, + int ChannelId, + int ProgramScheduleId, + ProgramSchedulePlayoutType ProgramSchedulePlayoutType) : IRequest>; +} diff --git a/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs new file mode 100644 index 000000000..a5de30e20 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs @@ -0,0 +1,75 @@ +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static LanguageExt.Prelude; +using static ErsatzTV.Application.Playouts.Mapper; +using Channel = ErsatzTV.Core.Domain.Channel; + +namespace ErsatzTV.Application.Playouts.Commands +{ + public class UpdatePlayoutHandler : IRequestHandler> + { + private readonly ChannelWriter _channel; + private readonly IChannelRepository _channelRepository; + private readonly IPlayoutRepository _playoutRepository; + private readonly IProgramScheduleRepository _programScheduleRepository; + + public UpdatePlayoutHandler( + IPlayoutRepository playoutRepository, + IChannelRepository channelRepository, + IProgramScheduleRepository programScheduleRepository, + ChannelWriter channel) + { + _playoutRepository = playoutRepository; + _channelRepository = channelRepository; + _programScheduleRepository = programScheduleRepository; + _channel = channel; + } + + public Task> Handle( + UpdatePlayout request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(c => ApplyUpdateRequest(c, request)) + .Bind(v => v.ToEitherAsync()); + + private async Task ApplyUpdateRequest(Playout p, UpdatePlayout update) + { + p.ChannelId = update.ChannelId; + p.ProgramScheduleId = update.ProgramScheduleId; + p.ProgramSchedulePlayoutType = update.ProgramSchedulePlayoutType; + await _playoutRepository.Update(p); + await _channel.WriteAsync(new BuildPlayout(p.Id)); + return ProjectToViewModel(p); + } + + private async Task> Validate(UpdatePlayout request) => + (await PlayoutMustExist(request), await ChannelMustExist(request), await ProgramScheduleMustExist(request), + ValidatePlayoutType(request)) + .Apply( + (playoutToUpdate, _, _, _) => playoutToUpdate); + + private async Task> PlayoutMustExist(UpdatePlayout updatePlayout) => + (await _playoutRepository.Get(updatePlayout.PlayoutId)) + .ToValidation("Playout does not exist."); + + private async Task> ChannelMustExist(UpdatePlayout createPlayout) => + (await _channelRepository.Get(createPlayout.ChannelId)) + .ToValidation("Channel does not exist."); + + private async Task> + ProgramScheduleMustExist(UpdatePlayout createPlayout) => + (await _programScheduleRepository.Get(createPlayout.ProgramScheduleId)) + .ToValidation("ProgramSchedule does not exist."); + + private Validation ValidatePlayoutType(UpdatePlayout createPlayout) => + Optional(createPlayout.ProgramSchedulePlayoutType) + .Filter(playoutType => playoutType != ProgramSchedulePlayoutType.None) + .ToValidation("[ProgramSchedulePlayoutType] must not be None"); + } +} diff --git a/ErsatzTV.Application/Playouts/Mapper.cs b/ErsatzTV.Application/Playouts/Mapper.cs new file mode 100644 index 000000000..da3ebc8b0 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Mapper.cs @@ -0,0 +1,36 @@ +using ErsatzTV.Core.Domain; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.Playouts +{ + internal static class Mapper + { + internal static PlayoutViewModel ProjectToViewModel(Playout playout) => + new( + playout.Id, + Project(playout.Channel), + Project(playout.ProgramSchedule), + playout.ProgramSchedulePlayoutType); + + internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) => + new(GetDisplayTitle(playoutItem.MediaItem), playoutItem.Start, GetDisplayDuration(playoutItem.MediaItem)); + + private static PlayoutChannelViewModel Project(Channel channel) => + new(channel.Id, channel.Name); + + private static PlayoutProgramScheduleViewModel Project(ProgramSchedule programSchedule) => + new(programSchedule.Id, programSchedule.Name); + + private static string GetDisplayTitle(MediaItem mediaItem) => + mediaItem.Metadata.MediaType == MediaType.TvShow && + Optional(mediaItem.Metadata.SeasonNumber).IsSome && + Optional(mediaItem.Metadata.EpisodeNumber).IsSome + ? $"{mediaItem.Metadata.Title} s{mediaItem.Metadata.SeasonNumber:00}e{mediaItem.Metadata.EpisodeNumber:00}" + : mediaItem.Metadata.Title; + + public static string GetDisplayDuration(MediaItem mediaItem) => + string.Format( + mediaItem.Metadata.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}", + mediaItem.Metadata.Duration); + } +} diff --git a/ErsatzTV.Application/Playouts/PlayoutChannelViewModel.cs b/ErsatzTV.Application/Playouts/PlayoutChannelViewModel.cs new file mode 100644 index 000000000..17baf62d6 --- /dev/null +++ b/ErsatzTV.Application/Playouts/PlayoutChannelViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Playouts +{ + public record PlayoutChannelViewModel(int Id, string Name); +} diff --git a/ErsatzTV.Application/Playouts/PlayoutItemViewModel.cs b/ErsatzTV.Application/Playouts/PlayoutItemViewModel.cs new file mode 100644 index 000000000..5e064c037 --- /dev/null +++ b/ErsatzTV.Application/Playouts/PlayoutItemViewModel.cs @@ -0,0 +1,6 @@ +using System; + +namespace ErsatzTV.Application.Playouts +{ + public record PlayoutItemViewModel(string Title, DateTimeOffset Start, string Duration); +} diff --git a/ErsatzTV.Application/Playouts/PlayoutProgramScheduleViewModel.cs b/ErsatzTV.Application/Playouts/PlayoutProgramScheduleViewModel.cs new file mode 100644 index 000000000..1d1ca0049 --- /dev/null +++ b/ErsatzTV.Application/Playouts/PlayoutProgramScheduleViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Playouts +{ + public record PlayoutProgramScheduleViewModel(int Id, string Name); +} diff --git a/ErsatzTV.Application/Playouts/PlayoutViewModel.cs b/ErsatzTV.Application/Playouts/PlayoutViewModel.cs new file mode 100644 index 000000000..978a35046 --- /dev/null +++ b/ErsatzTV.Application/Playouts/PlayoutViewModel.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Playouts +{ + public record PlayoutViewModel( + int Id, + PlayoutChannelViewModel Channel, + PlayoutProgramScheduleViewModel ProgramSchedule, + ProgramSchedulePlayoutType ProgramSchedulePlayoutType); +} diff --git a/ErsatzTV.Application/Playouts/Queries/GetAllPlayouts.cs b/ErsatzTV.Application/Playouts/Queries/GetAllPlayouts.cs new file mode 100644 index 000000000..40c360608 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetAllPlayouts.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.Playouts.Queries +{ + public record GetAllPlayouts : IRequest>; +} diff --git a/ErsatzTV.Application/Playouts/Queries/GetAllPlayoutsHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetAllPlayoutsHandler.cs new file mode 100644 index 000000000..0761bbf5f --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetAllPlayoutsHandler.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Playouts.Mapper; + +namespace ErsatzTV.Application.Playouts.Queries +{ + public class GetAllPlayoutsHandler : IRequestHandler> + { + private readonly IPlayoutRepository _playoutRepository; + + public GetAllPlayoutsHandler(IPlayoutRepository playoutRepository) => + _playoutRepository = playoutRepository; + + public Task> Handle( + GetAllPlayouts request, + CancellationToken cancellationToken) => + _playoutRepository.GetAll().Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutById.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutById.cs new file mode 100644 index 000000000..f4b9bcf6f --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Playouts.Queries +{ + public record GetPlayoutById(int Id) : IRequest>; +} diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs new file mode 100644 index 000000000..736f9d47d --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Playouts.Mapper; + +namespace ErsatzTV.Application.Playouts.Queries +{ + public class + GetPlayoutByIdHandler : IRequestHandler> + { + private readonly IPlayoutRepository _playoutRepository; + + public GetPlayoutByIdHandler(IPlayoutRepository playoutRepository) => + _playoutRepository = playoutRepository; + + public Task> Handle( + GetPlayoutById request, + CancellationToken cancellationToken) => + _playoutRepository.Get(request.Id) + .MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsById.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsById.cs new file mode 100644 index 000000000..169f655b0 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsById.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.Playouts.Queries +{ + public record GetPlayoutItemsById(int PlayoutId) : IRequest>; +} diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsByIdHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsByIdHandler.cs new file mode 100644 index 000000000..60ec68cc5 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsByIdHandler.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Playouts.Mapper; + +namespace ErsatzTV.Application.Playouts.Queries +{ + public class GetPlayoutItemsByIdHandler : IRequestHandler> + { + private readonly IPlayoutRepository _playoutRepository; + + public GetPlayoutItemsByIdHandler(IPlayoutRepository playoutRepository) => + _playoutRepository = playoutRepository; + + public Task> Handle( + GetPlayoutItemsById request, + CancellationToken cancellationToken) => + _playoutRepository.GetPlayoutItems(request.PlayoutId) + .Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs b/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs new file mode 100644 index 000000000..ebd5a337e --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs @@ -0,0 +1,18 @@ +using System; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.ProgramSchedules.Commands +{ + public record AddProgramScheduleItem( + int ProgramScheduleId, + StartType StartType, + TimeSpan? StartTime, + PlayoutMode PlayoutMode, + int MediaCollectionId, + int? MultipleCount, + TimeSpan? PlayoutDuration, + bool? OfflineTail) : IRequest>, IProgramScheduleItemRequest; +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItemHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItemHandler.cs new file mode 100644 index 000000000..787e6c123 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItemHandler.cs @@ -0,0 +1,61 @@ +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Application.Playouts.Commands; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.ProgramSchedules.Mapper; + +namespace ErsatzTV.Application.ProgramSchedules.Commands +{ + public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase, IRequestHandler> + { + private readonly ChannelWriter _channel; + private readonly IProgramScheduleRepository _programScheduleRepository; + + public AddProgramScheduleItemHandler( + IProgramScheduleRepository programScheduleRepository, + ChannelWriter channel) + : base(programScheduleRepository) + { + _programScheduleRepository = programScheduleRepository; + _channel = channel; + } + + public Task> Handle( + AddProgramScheduleItem request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(programSchedule => PersistItem(request, programSchedule)) + .Bind(v => v.ToEitherAsync()); + + private async Task PersistItem( + AddProgramScheduleItem request, + ProgramSchedule programSchedule) + { + int nextIndex = programSchedule.Items.Select(i => i.Index).DefaultIfEmpty(0).Max() + 1; + + ProgramScheduleItem item = BuildItem(programSchedule, nextIndex, request); + programSchedule.Items.Add(item); + + await _programScheduleRepository.Update(programSchedule); + + // rebuild any playouts that use this schedule + foreach (Playout playout in programSchedule.Playouts) + { + await _channel.WriteAsync(new BuildPlayout(playout.Id, true)); + } + + return ProjectToViewModel(item); + } + + private Task> Validate(AddProgramScheduleItem request) => + ProgramScheduleMustExist(request.ProgramScheduleId) + .BindT(programSchedule => PlayoutModeMustBeValid(request, programSchedule)); + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs b/ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs new file mode 100644 index 000000000..daa4c4aa7 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.ProgramSchedules.Commands +{ + public record CreateProgramSchedule(string Name, PlaybackOrder MediaCollectionPlaybackOrder) : + IRequest>; +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs new file mode 100644 index 000000000..1006665de --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs @@ -0,0 +1,43 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.ProgramSchedules.Mapper; + +namespace ErsatzTV.Application.ProgramSchedules.Commands +{ + public class + CreateProgramScheduleHandler : IRequestHandler> + { + private readonly IProgramScheduleRepository _programScheduleRepository; + + public CreateProgramScheduleHandler(IProgramScheduleRepository programScheduleRepository) => + _programScheduleRepository = programScheduleRepository; + + public Task> Handle( + CreateProgramSchedule request, + CancellationToken cancellationToken) => + Validate(request) + .Map(PersistProgramSchedule) + .ToEitherAsync(); + + private Task PersistProgramSchedule(ProgramSchedule c) => + _programScheduleRepository.Add(c).Map(ProjectToViewModel); + + private Validation Validate(CreateProgramSchedule request) => + ValidateName(request) + .Map( + name => new ProgramSchedule + { + Name = name, MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder + }); + + private Validation ValidateName(CreateProgramSchedule createProgramSchedule) => + createProgramSchedule.NotEmpty(c => c.Name) + .Bind(_ => createProgramSchedule.NotLongerThan(50)(c => c.Name)); + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramSchedule.cs b/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramSchedule.cs new file mode 100644 index 000000000..423f1a598 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramSchedule.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.ProgramSchedules.Commands +{ + public record DeleteProgramSchedule(int ProgramScheduleId) : IRequest>; +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleHandler.cs new file mode 100644 index 000000000..8cf4b9ed9 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleHandler.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.ProgramSchedules.Commands +{ + public class DeleteProgramScheduleHandler : IRequestHandler> + { + private readonly IProgramScheduleRepository _programScheduleRepository; + + public DeleteProgramScheduleHandler(IProgramScheduleRepository programScheduleRepository) => + _programScheduleRepository = programScheduleRepository; + + public async Task> Handle( + DeleteProgramSchedule request, + CancellationToken cancellationToken) => + (await ProgramScheduleMustExist(request)) + .Map(DoDeletion) + .ToEither(); + + private Task DoDeletion(int programScheduleId) => _programScheduleRepository.Delete(programScheduleId); + + private async Task> ProgramScheduleMustExist( + DeleteProgramSchedule deleteProgramSchedule) => + (await _programScheduleRepository.Get(deleteProgramSchedule.ProgramScheduleId)) + .ToValidation($"ProgramSchedule {deleteProgramSchedule.ProgramScheduleId} does not exist.") + .Map(c => c.Id); + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs b/ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs new file mode 100644 index 000000000..717e2897c --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs @@ -0,0 +1,15 @@ +using System; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.ProgramSchedules.Commands +{ + public interface IProgramScheduleItemRequest + { + TimeSpan? StartTime { get; } + int MediaCollectionId { get; } + PlayoutMode PlayoutMode { get; } + int? MultipleCount { get; } + TimeSpan? PlayoutDuration { get; } + bool? OfflineTail { get; } + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs new file mode 100644 index 000000000..dec8de188 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs @@ -0,0 +1,94 @@ +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.ProgramSchedules.Commands +{ + public abstract class ProgramScheduleItemCommandBase + { + private readonly IProgramScheduleRepository _programScheduleRepository; + + protected ProgramScheduleItemCommandBase(IProgramScheduleRepository programScheduleRepository) => + _programScheduleRepository = programScheduleRepository; + + protected async Task> ProgramScheduleMustExist(int programScheduleId) => + (await _programScheduleRepository.GetWithPlayouts(programScheduleId)) + .ToValidation("[ProgramScheduleId] does not exist."); + + protected Validation PlayoutModeMustBeValid( + IProgramScheduleItemRequest item, + ProgramSchedule programSchedule) + { + switch (item.PlayoutMode) + { + case PlayoutMode.Flood: + case PlayoutMode.One: + break; + case PlayoutMode.Multiple: + if (item.MultipleCount.GetValueOrDefault() <= 0) + { + return BaseError.New("[MultipleCount] must be greater than 0 for playout mode 'multiple'"); + } + + break; + case PlayoutMode.Duration: + if (item.PlayoutDuration is null) + { + return BaseError.New("[PlayoutDuration] is required for playout mode 'duration'"); + } + + if (item.OfflineTail is null) + { + return BaseError.New("[OfflineTail] is required for playout mode 'duration'"); + } + + break; + default: + return BaseError.New("[PlayoutMode] is invalid"); + } + + return programSchedule; + } + + protected ProgramScheduleItem BuildItem( + ProgramSchedule programSchedule, + int index, + IProgramScheduleItemRequest item) => + item.PlayoutMode switch + { + PlayoutMode.Flood => new ProgramScheduleItemFlood + { + ProgramScheduleId = programSchedule.Id, + Index = index, + StartTime = item.StartTime, + MediaCollectionId = item.MediaCollectionId + }, + PlayoutMode.One => new ProgramScheduleItemOne + { + ProgramScheduleId = programSchedule.Id, + Index = index, + StartTime = item.StartTime, + MediaCollectionId = item.MediaCollectionId + }, + PlayoutMode.Multiple => new ProgramScheduleItemMultiple + { + ProgramScheduleId = programSchedule.Id, + Index = index, + StartTime = item.StartTime, + MediaCollectionId = item.MediaCollectionId, + Count = item.MultipleCount.GetValueOrDefault() + }, + PlayoutMode.Duration => new ProgramScheduleItemDuration + { + ProgramScheduleId = programSchedule.Id, + Index = index, + StartTime = item.StartTime, + MediaCollectionId = item.MediaCollectionId, + PlayoutDuration = item.PlayoutDuration.GetValueOrDefault(), + OfflineTail = item.OfflineTail.GetValueOrDefault() + } + }; + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs new file mode 100644 index 000000000..0a1b051bf --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.ProgramSchedules.Commands +{ + public record ReplaceProgramScheduleItem( + int Index, + StartType StartType, + TimeSpan? StartTime, + PlayoutMode PlayoutMode, + int MediaCollectionId, + int? MultipleCount, + TimeSpan? PlayoutDuration, + bool? OfflineTail) : IProgramScheduleItemRequest; + + public record ReplaceProgramScheduleItems + (int ProgramScheduleId, List Items) : IRequest< + Either>>; +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs new file mode 100644 index 000000000..54e1587af --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Application.Playouts.Commands; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.ProgramSchedules.Mapper; + +namespace ErsatzTV.Application.ProgramSchedules.Commands +{ + public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase, + IRequestHandler>> + { + private readonly ChannelWriter _channel; + private readonly IProgramScheduleRepository _programScheduleRepository; + + public ReplaceProgramScheduleItemsHandler( + IProgramScheduleRepository programScheduleRepository, + ChannelWriter channel) + : base(programScheduleRepository) + { + _programScheduleRepository = programScheduleRepository; + _channel = channel; + } + + public Task>> Handle( + ReplaceProgramScheduleItems request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(programSchedule => PersistItems(request, programSchedule)) + .Bind(v => v.ToEitherAsync()); + + private async Task> PersistItems( + ReplaceProgramScheduleItems request, + ProgramSchedule programSchedule) + { + programSchedule.Items = request.Items.Map(i => BuildItem(programSchedule, i.Index, i)).ToList(); + + await _programScheduleRepository.Update(programSchedule); + + // rebuild any playouts that use this schedule + foreach (Playout playout in programSchedule.Playouts) + { + await _channel.WriteAsync(new BuildPlayout(playout.Id, true)); + } + + return programSchedule.Items.Map(ProjectToViewModel); + } + + private Task> Validate(ReplaceProgramScheduleItems request) => + ProgramScheduleMustExist(request.ProgramScheduleId) + .BindT(programSchedule => PlayoutModesMustBeValid(request, programSchedule)); + + private Validation PlayoutModesMustBeValid( + ReplaceProgramScheduleItems request, + ProgramSchedule programSchedule) => + request.Items.Map(item => PlayoutModeMustBeValid(item, programSchedule)).Sequence() + .Map(_ => programSchedule); + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs b/ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs new file mode 100644 index 000000000..086a2feab --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.ProgramSchedules.Commands +{ + public record UpdateProgramSchedule + ( + int ProgramScheduleId, + string Name, + PlaybackOrder MediaCollectionPlaybackOrder) : IRequest>; +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs new file mode 100644 index 000000000..0b6f104aa --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs @@ -0,0 +1,72 @@ +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Application.Playouts.Commands; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.ProgramSchedules.Mapper; + +namespace ErsatzTV.Application.ProgramSchedules.Commands +{ + public class + UpdateProgramScheduleHandler : IRequestHandler> + { + private readonly ChannelWriter _channel; + private readonly IProgramScheduleRepository _programScheduleRepository; + + public UpdateProgramScheduleHandler( + IProgramScheduleRepository programScheduleRepository, + ChannelWriter channel) + { + _programScheduleRepository = programScheduleRepository; + _channel = channel; + } + + public Task> Handle( + UpdateProgramSchedule request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(c => ApplyUpdateRequest(c, request)) + .Bind(v => v.ToEitherAsync()); + + private async Task ApplyUpdateRequest( + ProgramSchedule programSchedule, + UpdateProgramSchedule update) + { + // we only need to rebuild playouts if the playback order has been modified + bool needToRebuildPlayout = + programSchedule.MediaCollectionPlaybackOrder != update.MediaCollectionPlaybackOrder; + + programSchedule.Name = update.Name; + programSchedule.MediaCollectionPlaybackOrder = update.MediaCollectionPlaybackOrder; + await _programScheduleRepository.Update(programSchedule); + + if (needToRebuildPlayout) + { + foreach (Playout playout in programSchedule.Playouts) + { + await _channel.WriteAsync(new BuildPlayout(playout.Id, true)); + } + } + + return ProjectToViewModel(programSchedule); + } + + private async Task> Validate(UpdateProgramSchedule request) => + (await ProgramScheduleMustExist(request), ValidateName(request)) + .Apply((programScheduleToUpdate, _) => programScheduleToUpdate); + + private async Task> ProgramScheduleMustExist( + UpdateProgramSchedule updateProgramSchedule) => + (await _programScheduleRepository.GetWithPlayouts(updateProgramSchedule.ProgramScheduleId)) + .ToValidation("ProgramSchedule does not exist."); + + private Validation ValidateName(UpdateProgramSchedule updateProgramSchedule) => + updateProgramSchedule.NotEmpty(c => c.Name) + .Bind(_ => updateProgramSchedule.NotLongerThan(50)(c => c.Name)); + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/Mapper.cs b/ErsatzTV.Application/ProgramSchedules/Mapper.cs new file mode 100644 index 000000000..f5f96d5e9 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Mapper.cs @@ -0,0 +1,46 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.ProgramSchedules +{ + internal static class Mapper + { + internal static ProgramScheduleViewModel ProjectToViewModel(ProgramSchedule programSchedule) => + new(programSchedule.Id, programSchedule.Name, programSchedule.MediaCollectionPlaybackOrder); + + internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) => + programScheduleItem switch + { + ProgramScheduleItemDuration duration => + new ProgramScheduleItemDurationViewModel( + duration.Id, + duration.Index, + duration.StartType, + duration.StartTime, + MediaCollections.Mapper.ProjectToViewModel(duration.MediaCollection), + duration.PlayoutDuration, + duration.OfflineTail), + ProgramScheduleItemFlood flood => + new ProgramScheduleItemFloodViewModel( + flood.Id, + flood.Index, + flood.StartType, + flood.StartTime, + MediaCollections.Mapper.ProjectToViewModel(flood.MediaCollection)), + ProgramScheduleItemMultiple multiple => + new ProgramScheduleItemMultipleViewModel( + multiple.Id, + multiple.Index, + multiple.StartType, + multiple.StartTime, + MediaCollections.Mapper.ProjectToViewModel(multiple.MediaCollection), + multiple.Count), + ProgramScheduleItemOne one => + new ProgramScheduleItemOneViewModel( + one.Id, + one.Index, + one.StartType, + one.StartTime, + MediaCollections.Mapper.ProjectToViewModel(one.MediaCollection)) + }; + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs new file mode 100644 index 000000000..76fc0ab3b --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs @@ -0,0 +1,31 @@ +using System; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.ProgramSchedules +{ + public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewModel + { + public ProgramScheduleItemDurationViewModel( + int id, + int index, + StartType startType, + TimeSpan? startTime, + MediaCollectionViewModel mediaCollection, + TimeSpan playoutDuration, + bool offlineTail) : base( + id, + index, + startType, + startTime, + PlayoutMode.Duration, + mediaCollection) + { + PlayoutDuration = playoutDuration; + OfflineTail = offlineTail; + } + + public TimeSpan PlayoutDuration { get; } + public bool OfflineTail { get; } + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs new file mode 100644 index 000000000..13105513f --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs @@ -0,0 +1,24 @@ +using System; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.ProgramSchedules +{ + public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel + { + public ProgramScheduleItemFloodViewModel( + int id, + int index, + StartType startType, + TimeSpan? startTime, + MediaCollectionViewModel mediaCollection) : base( + id, + index, + startType, + startTime, + PlayoutMode.Flood, + mediaCollection) + { + } + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs new file mode 100644 index 000000000..13cc47031 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs @@ -0,0 +1,26 @@ +using System; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.ProgramSchedules +{ + public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewModel + { + public ProgramScheduleItemMultipleViewModel( + int id, + int index, + StartType startType, + TimeSpan? startTime, + MediaCollectionViewModel mediaCollection, + int count) : base( + id, + index, + startType, + startTime, + PlayoutMode.Duration, + mediaCollection) => + Count = count; + + public int Count { get; } + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs new file mode 100644 index 000000000..62f55f10d --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs @@ -0,0 +1,24 @@ +using System; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.ProgramSchedules +{ + public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel + { + public ProgramScheduleItemOneViewModel( + int id, + int index, + StartType startType, + TimeSpan? startTime, + MediaCollectionViewModel mediaCollection) : base( + id, + index, + startType, + startTime, + PlayoutMode.One, + mediaCollection) + { + } + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs new file mode 100644 index 000000000..7a0ed5e82 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs @@ -0,0 +1,14 @@ +using System; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.ProgramSchedules +{ + public abstract record ProgramScheduleItemViewModel( + int Id, + int Index, + StartType StartType, + TimeSpan? StartTime, + PlayoutMode PlayoutMode, + MediaCollectionViewModel MediaCollection); +} diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs new file mode 100644 index 000000000..be704fabc --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.ProgramSchedules +{ + public record ProgramScheduleViewModel(int Id, string Name, PlaybackOrder MediaCollectionPlaybackOrder); +} diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetAllProgramSchedules.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetAllProgramSchedules.cs new file mode 100644 index 000000000..db8553438 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetAllProgramSchedules.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.ProgramSchedules.Queries +{ + public record GetAllProgramSchedules : IRequest>; +} diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetAllProgramSchedulesHandler.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetAllProgramSchedulesHandler.cs new file mode 100644 index 000000000..2f2e7ea7b --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetAllProgramSchedulesHandler.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using MediatR; +using static ErsatzTV.Application.ProgramSchedules.Mapper; + +namespace ErsatzTV.Application.ProgramSchedules.Queries +{ + public class GetAllProgramSchedulesHandler : IRequestHandler> + { + private readonly IProgramScheduleRepository _programScheduleRepository; + + public GetAllProgramSchedulesHandler(IProgramScheduleRepository programScheduleRepository) => + _programScheduleRepository = programScheduleRepository; + + public async Task> Handle( + GetAllProgramSchedules request, + CancellationToken cancellationToken) => + (await _programScheduleRepository.GetAll()).Map(ProjectToViewModel).ToList(); + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleById.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleById.cs new file mode 100644 index 000000000..62265d366 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.ProgramSchedules.Queries +{ + public record GetProgramScheduleById(int Id) : IRequest>; +} diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleByIdHandler.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleByIdHandler.cs new file mode 100644 index 000000000..57e2dca01 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleByIdHandler.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.ProgramSchedules.Mapper; + +namespace ErsatzTV.Application.ProgramSchedules.Queries +{ + public class + GetProgramScheduleByIdHandler : IRequestHandler> + { + private readonly IProgramScheduleRepository _programScheduleRepository; + + public GetProgramScheduleByIdHandler(IProgramScheduleRepository programScheduleRepository) => + _programScheduleRepository = programScheduleRepository; + + public Task> Handle( + GetProgramScheduleById request, + CancellationToken cancellationToken) => + _programScheduleRepository.Get(request.Id) + .MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItems.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItems.cs new file mode 100644 index 000000000..8e75be5e3 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItems.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.ProgramSchedules.Queries +{ + public record GetProgramScheduleItems(int Id) : IRequest>>; +} diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs new file mode 100644 index 000000000..724835415 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.ProgramSchedules.Mapper; + +namespace ErsatzTV.Application.ProgramSchedules.Queries +{ + public class GetProgramScheduleItemsHandler : IRequestHandler>> + { + private readonly IProgramScheduleRepository _programScheduleRepository; + + public GetProgramScheduleItemsHandler(IProgramScheduleRepository programScheduleRepository) => + _programScheduleRepository = programScheduleRepository; + + public Task>> Handle( + GetProgramScheduleItems request, + CancellationToken cancellationToken) => + _programScheduleRepository.GetItems(request.Id) + .MapT(programScheduleItems => programScheduleItems.Map(ProjectToViewModel)); + } +} diff --git a/ErsatzTV.Application/Resolutions/FFmpegProfileResolutionViewModel.cs b/ErsatzTV.Application/Resolutions/FFmpegProfileResolutionViewModel.cs new file mode 100644 index 000000000..281055252 --- /dev/null +++ b/ErsatzTV.Application/Resolutions/FFmpegProfileResolutionViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Resolutions +{ + public record ResolutionViewModel(int Id, string Name, int Width, int Height); +} diff --git a/ErsatzTV.Application/Resolutions/Mapper.cs b/ErsatzTV.Application/Resolutions/Mapper.cs new file mode 100644 index 000000000..47f6da43c --- /dev/null +++ b/ErsatzTV.Application/Resolutions/Mapper.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Resolutions +{ + internal static class Mapper + { + internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) => + new(resolution.Id, resolution.Name, resolution.Width, resolution.Height); + } +} diff --git a/ErsatzTV.Application/Resolutions/Queries/GetAllResolutions.cs b/ErsatzTV.Application/Resolutions/Queries/GetAllResolutions.cs new file mode 100644 index 000000000..ff9ee80ac --- /dev/null +++ b/ErsatzTV.Application/Resolutions/Queries/GetAllResolutions.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.Resolutions.Queries +{ + public record GetAllResolutions : IRequest>; +} diff --git a/ErsatzTV.Application/Resolutions/Queries/GetAllResolutionsHandler.cs b/ErsatzTV.Application/Resolutions/Queries/GetAllResolutionsHandler.cs new file mode 100644 index 000000000..784ea62ee --- /dev/null +++ b/ErsatzTV.Application/Resolutions/Queries/GetAllResolutionsHandler.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Resolutions.Mapper; + +namespace ErsatzTV.Application.Resolutions.Queries +{ + public class GetAllResolutionsHandler : IRequestHandler> + { + private readonly IResolutionRepository _resolutionRepository; + + public GetAllResolutionsHandler(IResolutionRepository resolutionRepository) => + _resolutionRepository = resolutionRepository; + + public Task> Handle(GetAllResolutions request, CancellationToken cancellationToken) => + _resolutionRepository.GetAll().Map(resolutions => resolutions.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs new file mode 100644 index 000000000..1f7f30690 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs @@ -0,0 +1,49 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.Streaming.Queries +{ + public abstract class FFmpegProcessHandler : IRequestHandler> + where T : FFmpegProcessRequest + { + private readonly IChannelRepository _channelRepository; + private readonly IConfigElementRepository _configElementRepository; + + protected FFmpegProcessHandler( + IChannelRepository channelRepository, + IConfigElementRepository configElementRepository) + { + _channelRepository = channelRepository; + _configElementRepository = configElementRepository; + } + + public Task> Handle(T request, CancellationToken cancellationToken) => + Validate(request) + .Map(v => v.ToEither>()) + .BindT(tuple => GetProcess(request, tuple.Item1, tuple.Item2)); + + protected abstract Task> GetProcess(T request, Channel channel, string ffmpegPath); + + private async Task>> Validate(T request) => + (await ChannelMustExist(request), await FFmpegPathMustExist()) + .Apply((channel, ffmpegPath) => Tuple(channel, ffmpegPath)); + + private async Task> ChannelMustExist(T request) => + (await _channelRepository.GetByNumber(request.ChannelNumber)) + .ToValidation($"Channel number {request.ChannelNumber} does not exist."); + + private Task> FFmpegPathMustExist() => + _configElementRepository.GetValue(ConfigElementKey.FFmpegPath) + .FilterT(File.Exists) + .Map(maybePath => maybePath.ToValidation("FFmpeg path does not exist on filesystem")); + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs new file mode 100644 index 000000000..10278e146 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs @@ -0,0 +1,9 @@ +using System.Diagnostics; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Streaming.Queries +{ + public record FFmpegProcessRequest(int ChannelNumber) : IRequest>; +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetConcatPlaylistByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetConcatPlaylistByChannelNumber.cs new file mode 100644 index 000000000..2ab6231b4 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetConcatPlaylistByChannelNumber.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.FFmpeg; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Streaming.Queries +{ + public record GetConcatPlaylistByChannelNumber + (string Scheme, string Host, int ChannelNumber) : IRequest>; +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetConcatPlaylistByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetConcatPlaylistByChannelNumberHandler.cs new file mode 100644 index 000000000..517c1c2c3 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetConcatPlaylistByChannelNumberHandler.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Streaming.Queries +{ + public class + GetConcatPlaylistByChannelNumberHandler : IRequestHandler> + { + private readonly IChannelRepository _channelRepository; + + public GetConcatPlaylistByChannelNumberHandler(IChannelRepository channelRepository) => + _channelRepository = channelRepository; + + public Task> Handle( + GetConcatPlaylistByChannelNumber request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(channel => new ConcatPlaylist(request.Scheme, request.Host, channel.Number)) + .Map(v => v.ToEither()); + + private Task> Validate(GetConcatPlaylistByChannelNumber request) => + ChannelMustExist(request); + + private async Task> ChannelMustExist(GetConcatPlaylistByChannelNumber request) => + (await _channelRepository.GetByNumber(request.ChannelNumber)) + .ToValidation($"Channel number {request.ChannelNumber} does not exist."); + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs new file mode 100644 index 000000000..cf4320379 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs @@ -0,0 +1,14 @@ +namespace ErsatzTV.Application.Streaming.Queries +{ + public record GetConcatProcessByChannelNumber : FFmpegProcessRequest + { + public GetConcatProcessByChannelNumber(string scheme, string host, int channelNumber) : base(channelNumber) + { + Scheme = scheme; + Host = host; + } + + public string Scheme { get; } + public string Host { get; } + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs new file mode 100644 index 000000000..c6ea068a1 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.Streaming.Queries +{ + public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler + { + private readonly FFmpegProcessService _ffmpegProcessService; + + public GetConcatProcessByChannelNumberHandler( + IChannelRepository channelRepository, + IConfigElementRepository configElementRepository, + FFmpegProcessService ffmpegProcessService) + : base(channelRepository, configElementRepository) => + _ffmpegProcessService = ffmpegProcessService; + + protected override Task> GetProcess( + GetConcatProcessByChannelNumber request, + Channel channel, + string ffmpegPath) => + Right( + _ffmpegProcessService.ConcatChannel( + ffmpegPath, + channel, + request.Scheme, + request.Host)).AsTask(); + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumber.cs new file mode 100644 index 000000000..92ae2925b --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumber.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Streaming.Queries +{ + public record GetHlsPlaylistByChannelNumber + (string Scheme, string Host, int ChannelNumber) : IRequest>; +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumberHandler.cs new file mode 100644 index 000000000..920867893 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumberHandler.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using Serilog; + +namespace ErsatzTV.Application.Streaming.Queries +{ + public class + GetHlsPlaylistByChannelNumberHandler : IRequestHandler> + { + private readonly IChannelRepository _channelRepository; + private readonly IPlayoutRepository _playoutRepository; + + public GetHlsPlaylistByChannelNumberHandler( + IChannelRepository channelRepository, + IPlayoutRepository playoutRepository) + { + _channelRepository = channelRepository; + _playoutRepository = playoutRepository; + } + + public Task> Handle( + GetHlsPlaylistByChannelNumber request, + CancellationToken cancellationToken) => + ChannelMustExist(request) + .Map(v => v.ToEither()) + .BindT(channel => GetPlaylist(request, channel)); + + private async Task> GetPlaylist( + GetHlsPlaylistByChannelNumber request, + Channel channel) + { + DateTimeOffset now = DateTimeOffset.Now; + Option maybePlayoutItem = await _playoutRepository.GetPlayoutItem(channel.Id, now); + return maybePlayoutItem.Match>( + playoutItem => + { + double timeRemaining = Math.Abs((playoutItem.Finish - now).TotalSeconds); + return $@"#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:18000 +#EXTINF:{timeRemaining:F2}, +{request.Scheme}://{request.Host}/ffmpeg/stream/{request.ChannelNumber} +"; + }, + () => + { + // TODO: playlist for error stream + Log.Logger.Error("Unable to locate playout item for m3u8"); + return BaseError.New($"Unable to locate playout item for channel {channel.Number}"); + }); + } + + private async Task> ChannelMustExist(GetHlsPlaylistByChannelNumber request) => + (await _channelRepository.GetByNumber(request.ChannelNumber)) + .ToValidation($"Channel number {request.ChannelNumber} does not exist."); + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs new file mode 100644 index 000000000..62d164576 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Application.Streaming.Queries +{ + public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest + { + public GetPlayoutItemProcessByChannelNumber(int channelNumber) : base(channelNumber) + { + } + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs new file mode 100644 index 000000000..30f653acb --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -0,0 +1,50 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.Streaming.Queries +{ + public class + GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler + { + private readonly FFmpegProcessService _ffmpegProcessService; + private readonly IPlayoutRepository _playoutRepository; + + public GetPlayoutItemProcessByChannelNumberHandler( + IChannelRepository channelRepository, + IConfigElementRepository configElementRepository, + IPlayoutRepository playoutRepository, + FFmpegProcessService ffmpegProcessService) + : base(channelRepository, configElementRepository) + { + _playoutRepository = playoutRepository; + _ffmpegProcessService = ffmpegProcessService; + } + + protected override async Task> GetProcess( + GetPlayoutItemProcessByChannelNumber _, + Channel channel, + string ffmpegPath) + { + DateTimeOffset now = DateTimeOffset.Now; + Option maybePlayoutItem = await _playoutRepository.GetPlayoutItem(channel.Id, now); + return maybePlayoutItem.Match>( + playoutItem => _ffmpegProcessService.ForPlayoutItem(ffmpegPath, channel, playoutItem, now), + () => + { + if (channel.FFmpegProfile.Transcode) + { + return _ffmpegProcessService.ForOfflineImage(ffmpegPath, channel); + } + + return BaseError.New( + $"Unable to locate playout item for channel {channel.Number}; offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'"); + }); + } + } +} diff --git a/ErsatzTV.Application/Validators/GetMemberName.cs b/ErsatzTV.Application/Validators/GetMemberName.cs new file mode 100644 index 000000000..a57fbf8d0 --- /dev/null +++ b/ErsatzTV.Application/Validators/GetMemberName.cs @@ -0,0 +1,20 @@ +using System; +using System.Linq.Expressions; +using System.Reflection; + +namespace ErsatzTV +{ + public static partial class Validators + { + private static string GetMemberName(Expression> expression) + { + var member = expression.Body as MemberExpression; + if (member?.Member is PropertyInfo propertyInfo) + { + return propertyInfo.Name; + } + + throw new ArgumentException("Expression is not a property"); + } + } +} diff --git a/ErsatzTV.Application/Validators/NumericValidation.cs b/ErsatzTV.Application/Validators/NumericValidation.cs new file mode 100644 index 000000000..4bdd8b043 --- /dev/null +++ b/ErsatzTV.Application/Validators/NumericValidation.cs @@ -0,0 +1,19 @@ +using System; +using System.Linq.Expressions; +using ErsatzTV.Core; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV +{ + public static partial class Validators + { + public static Func>, Validation> + AtLeast(this T input, int minimum) => + value => Optional(value) + .Map(i => i.Compile()(input)) + .Where(i => i >= minimum) + .ToValidation( + $"[{GetMemberName(value)}] must be greater or equal to {minimum}"); + } +} diff --git a/ErsatzTV.Application/Validators/StringValidation.cs b/ErsatzTV.Application/Validators/StringValidation.cs new file mode 100644 index 000000000..2d4f8fcb8 --- /dev/null +++ b/ErsatzTV.Application/Validators/StringValidation.cs @@ -0,0 +1,24 @@ +using System; +using System.Linq.Expressions; +using ErsatzTV.Core; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV +{ + public static partial class Validators + { + public static Func>, Validation> NotLongerThan( + this T input, + int maxLength) => + expression => Optional(expression) + .Map(exp => exp.Compile()(input)) + .Where(s => s.Length <= maxLength) + .ToValidation($"[{GetMemberName(expression)}] must not be longer than {maxLength}"); + + public static Validation NotEmpty(this T input, Expression> expression) => + Optional(expression.Compile()(input)) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToValidation($"[{GetMemberName(expression)}] is an empty string"); + } +} diff --git a/ErsatzTV.CommandLine/Commands/ChannelCommand.cs b/ErsatzTV.CommandLine/Commands/ChannelCommand.cs new file mode 100644 index 000000000..70dace925 --- /dev/null +++ b/ErsatzTV.CommandLine/Commands/ChannelCommand.cs @@ -0,0 +1,106 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CliFx; +using CliFx.Attributes; +using ErsatzTV.Api.Sdk.Api; +using ErsatzTV.Api.Sdk.Model; +using LanguageExt; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; + +namespace ErsatzTV.CommandLine.Commands +{ + [Command("channel", Description = "Create or rename a channel")] + public class ChannelCommand : ICommand + { + private readonly ChannelsApi _channelsApi; + private readonly FFmpegProfileApi _ffmpegProfileApi; + private readonly ILogger _logger; + + public ChannelCommand(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _channelsApi = new ChannelsApi(configuration["ServerUrl"]); + _ffmpegProfileApi = new FFmpegProfileApi(configuration["ServerUrl"]); + } + + [CommandParameter(0, Name = "channel-number", Description = "The channel number")] + public int Number { get; set; } + + [CommandParameter(1, Name = "channel-name", Description = "The channel name")] + public string Name { get; set; } + + [CommandParameter(2, Name = "streaming-mode", Description = "The streaming mode")] + public StreamingMode StreamingMode { get; set; } + + [CommandOption("ffmpeg-profile", Description = "The ffmpeg profile name")] + public string FFmpegProfileName { get; set; } + + public async ValueTask ExecuteAsync(IConsole console) + { + try + { + Option maybeChannel = await _channelsApi.ApiChannelsGetAsync() + .Map(list => Optional(list.SingleOrDefault(c => c.Number == Number))); + + FFmpegProfileViewModel ffmpegProfile = await _ffmpegProfileApi.ApiFfmpegProfilesGetAsync() + .Map( + list => Optional(list.SingleOrDefault(p => p.Name == FFmpegProfileName)) + .IfNone(new FFmpegProfileViewModel { Id = 1 })); + + await maybeChannel.Match( + channel => RenameChannel(channel, ffmpegProfile), + () => AddChannel(ffmpegProfile)); + } + catch (Exception ex) + { + _logger.LogError("Unable to synchronize channel: {Message}", ex.Message); + } + } + + private async ValueTask RenameChannel(ChannelViewModel existing, FFmpegProfileViewModel ffmpegProfile) + { + int newFFmpegProfileId = string.IsNullOrWhiteSpace(FFmpegProfileName) + ? existing.FfmpegProfileId + : ffmpegProfile.Id; + + if (existing.Name != Name || existing.FfmpegProfileId != newFFmpegProfileId || + existing.StreamingMode != StreamingMode) + { + var updateChannel = new UpdateChannel( + existing.Id, + Name, + existing.Number, + newFFmpegProfileId, + existing.Logo, + StreamingMode); + + await _channelsApi.ApiChannelsPatchAsync(updateChannel); + } + + _logger.LogInformation( + "Successfully synchronized channel {ChannelNumber} - {ChannelName}", + Number, + Name); + } + + private async ValueTask AddChannel(FFmpegProfileViewModel ffmpegProfile) + { + var createChannel = new CreateChannel( + Name, + Number, + ffmpegProfile.Id, + null, + StreamingMode); + + await _channelsApi.ApiChannelsPostAsync(createChannel); + + _logger.LogInformation( + "Successfully created channel {ChannelNumber} - {ChannelName}", + Number, + Name); + } + } +} diff --git a/ErsatzTV.CommandLine/Commands/ConfigCommand.cs b/ErsatzTV.CommandLine/Commands/ConfigCommand.cs new file mode 100644 index 000000000..511150ab5 --- /dev/null +++ b/ErsatzTV.CommandLine/Commands/ConfigCommand.cs @@ -0,0 +1,31 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using CliFx; +using CliFx.Attributes; + +namespace ErsatzTV.CommandLine.Commands +{ + [Command("config", Description = "Configure ErsatzTV server url")] + public class ConfigCommand : ICommand + { + [CommandParameter(0, Name = "server-url", Description = "The url of the ErsatzTV server")] + public string ServerUrl { get; set; } + + public async ValueTask ExecuteAsync(IConsole console) + { + // TODO: validate URL + + string configFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ersatztv"); + + string configFile = Path.Combine(configFolder, "cli.json"); + + var config = new Config { ServerUrl = ServerUrl }; + string contents = JsonSerializer.Serialize(config); + await File.WriteAllTextAsync(configFile, contents); + } + } +} diff --git a/ErsatzTV.CommandLine/Commands/FFmpegProfileCommand.cs b/ErsatzTV.CommandLine/Commands/FFmpegProfileCommand.cs new file mode 100644 index 000000000..254b8d40c --- /dev/null +++ b/ErsatzTV.CommandLine/Commands/FFmpegProfileCommand.cs @@ -0,0 +1,151 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CliFx; +using CliFx.Attributes; +using ErsatzTV.Api.Sdk.Api; +using ErsatzTV.Api.Sdk.Model; +using LanguageExt; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; + +namespace ErsatzTV.CommandLine.Commands +{ + [Command("ffmpeg-profile", Description = "Synchronize an ffmpeg profile")] + public class FFmpegProfileCommand : ICommand + { + private readonly FFmpegProfileApi _ffmpegProfileApi; + private readonly ILogger _logger; + + public FFmpegProfileCommand(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _ffmpegProfileApi = new FFmpegProfileApi(configuration["ServerUrl"]); + } + + [CommandParameter(0, Name = "profile-name", Description = "The ffmpeg profile name")] + public string Name { get; set; } + + [CommandOption("thread-count", Description = "The number of threads")] + public int ThreadCount { get; set; } = 4; + + [CommandOption("transcode", Description = "Whether to transcode all media")] + public bool Transcode { get; set; } = true; + + // public int ResolutionId { get; set; } = resolution.Id; + // Resolution { get; set; } = resolution; + [CommandOption("resolution", Description = "The resolution")] + public DesiredResolution Resolution { get; set; } = DesiredResolution.W1920H1080; + + [CommandOption("video-codec", Description = "The video codec")] + public string VideoCodec { get; set; } = "libx264"; + + [CommandOption("audio-codec", Description = "The audio codec")] + public string AudioCodec { get; set; } = "ac3"; + + [CommandOption("video-bitrate", Description = "The video bitrate in kBit/s")] + public int VideoBitrate { get; set; } = 2000; + + [CommandOption("video-buffer-size", Description = "The video buffer size in kBit")] + public int VideoBufferSize { get; set; } = 2000; + + [CommandOption("audio-bitrate", Description = "The audio bitrate in kBit/s")] + public int AudioBitrate { get; set; } = 192; + + [CommandOption("audio-buffer-size", Description = "The audio buffer size in kBits")] + public int AudioBufferSize { get; set; } = 50; + + [CommandOption("audio-volume", Description = "The audio volume as a whole number percent")] + public int AudioVolume { get; set; } = 100; + + [CommandOption("audio-channels", Description = "The number of audio channels")] + public int AudioChannels { get; set; } = 2; + + [CommandOption("audio-sample-rate", Description = "The audio sample rate in kHz")] + public int AudioSampleRate { get; set; } = 48; + + [CommandOption("normalize-resolution", Description = "Whether to normalize the resolution of all media")] + public bool NormalizeResolution { get; set; } = true; + + [CommandOption("normalize-video-codec", Description = "Whether to normalize the video codec of all media")] + public bool NormalizeVideoCodec { get; set; } = true; + + [CommandOption("normalize-audio-codec", Description = "Whether to normalize the audio codec of all media")] + public bool NormalizeAudioCodec { get; set; } = true; + + [CommandOption( + "normalize-audio", + Description = "Whether to normalize audio channels and sample rate of all media")] + public bool NormalizeAudio { get; set; } = true; + + public async ValueTask ExecuteAsync(IConsole console) + { + try + { + Option maybeFFmpegProfile = await _ffmpegProfileApi.ApiFfmpegProfilesGetAsync() + .Map(list => Optional(list.SingleOrDefault(p => p.Name == Name))); + + await maybeFFmpegProfile.Match(UpdateProfile, AddProfile); + } + catch (Exception ex) + { + _logger.LogError("Unable to synchronize ffmpeg profile: {Message}", ex.Message); + } + } + + private async ValueTask UpdateProfile(FFmpegProfileViewModel existing) + { + var updateFFmpegProfile = new UpdateFFmpegProfile( + existing.Id, + Name, + ThreadCount, + Transcode, + (int) Resolution, + NormalizeResolution, + VideoCodec, + NormalizeVideoCodec, + VideoBitrate, + VideoBufferSize, + AudioCodec, + NormalizeAudioCodec, + AudioBitrate, + AudioBufferSize, + AudioVolume, + AudioChannels, + AudioSampleRate, + NormalizeAudio); + + await _ffmpegProfileApi.ApiFfmpegProfilesPatchAsync(updateFFmpegProfile); + + _logger.LogInformation("Successfully synchronized ffmpeg profile {ProfileName}", Name); + } + + private async ValueTask AddProfile() + { + var createFFmpegProfile = new CreateFFmpegProfile( + Name, + ThreadCount, + Transcode, + (int) Resolution, + NormalizeResolution, + VideoCodec, + NormalizeVideoCodec, + VideoBitrate, + VideoBufferSize, + AudioCodec, + NormalizeAudioCodec, + AudioBitrate, + AudioBufferSize, + AudioVolume, + AudioChannels, + AudioSampleRate, + NormalizeAudio); + + + await _ffmpegProfileApi.ApiFfmpegProfilesPostAsync(createFFmpegProfile); + + _logger.LogInformation("Successfully created ffmpeg profile {ProfileName}", Name); + } + } +} diff --git a/ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionClearCommand.cs b/ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionClearCommand.cs new file mode 100644 index 000000000..114ecdd39 --- /dev/null +++ b/ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionClearCommand.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CliFx; +using CliFx.Attributes; +using ErsatzTV.Api.Sdk.Api; +using ErsatzTV.Api.Sdk.Model; +using LanguageExt; +using LanguageExt.Common; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; + +namespace ErsatzTV.CommandLine.Commands.MediaCollections +{ + [Command("collection clear", Description = "Removes all items from a media collection")] + public class MediaCollectionClearCommand : ICommand + { + private readonly ILogger _logger; + private readonly string _serverUrl; + + public MediaCollectionClearCommand(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _serverUrl = configuration["ServerUrl"]; + } + + [CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")] + public string Name { get; set; } + + public async ValueTask ExecuteAsync(IConsole console) + { + try + { + CancellationToken cancellationToken = console.GetCancellationToken(); + + Either result = await ClearMediaCollection(cancellationToken); + + result.Match( + _ => _logger.LogInformation("Successfully cleared media collection {MediaCollection}", Name), + error => _logger.LogError( + "Unable to clear media collection: {Error}", + error.Message)); + } + catch (Exception ex) + { + _logger.LogError("Unable to clear media collection: {Error}", ex.Message); + } + } + + private async Task> ClearMediaCollection(CancellationToken cancellationToken) => + await EnsureMediaCollectionExists(cancellationToken) + .BindAsync(mediaCollectionId => ClearMediaCollectionImpl(mediaCollectionId, cancellationToken)); + + private async Task> EnsureMediaCollectionExists(CancellationToken cancellationToken) + { + var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl); + Option maybeExisting = + (await mediaCollectionsApi.ApiMediaCollectionsGetAsync(cancellationToken)) + .SingleOrDefault(mc => mc.Name == Name); + return await maybeExisting.MatchAsync( + existing => existing.Id, + async () => + { + var data = new CreateSimpleMediaCollection(Name); + MediaCollectionViewModel result = + await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken); + return result.Id; + }); + } + + private async Task> ClearMediaCollectionImpl( + int mediaCollectionId, + CancellationToken cancellationToken) + { + var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl); + await mediaCollectionsApi.ApiMediaCollectionsIdItemsPutAsync( + mediaCollectionId, + new List(), + cancellationToken); + return unit; + } + } +} diff --git a/ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionCreateCommand.cs b/ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionCreateCommand.cs new file mode 100644 index 000000000..02367f67f --- /dev/null +++ b/ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionCreateCommand.cs @@ -0,0 +1,72 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CliFx; +using CliFx.Attributes; +using ErsatzTV.Api.Sdk.Api; +using ErsatzTV.Api.Sdk.Model; +using LanguageExt; +using LanguageExt.Common; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; + +namespace ErsatzTV.CommandLine.Commands.MediaCollections +{ + [Command("collection create", Description = "Creates a new media collection")] + public class MediaCollectionCreateCommand : ICommand + { + private readonly ILogger _logger; + private readonly string _serverUrl; + + public MediaCollectionCreateCommand(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _serverUrl = configuration["ServerUrl"]; + } + + [CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")] + public string Name { get; set; } + + public async ValueTask ExecuteAsync(IConsole console) + { + try + { + CancellationToken cancellationToken = console.GetCancellationToken(); + + Either result = await CreateMediaCollection(cancellationToken); + result.IfLeft(error => _logger.LogError("Unable to create media collection: {Error}", error.Message)); + } + catch (Exception ex) + { + _logger.LogError("Unable to create media collection: {Error}", ex.Message); + } + } + + private async Task> CreateMediaCollection(CancellationToken cancellationToken) => + await EnsureMediaCollectionExists(cancellationToken); + + private async Task> EnsureMediaCollectionExists(CancellationToken cancellationToken) + { + var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl); + + bool needToAdd = await mediaCollectionsApi + .ApiMediaCollectionsGetAsync(cancellationToken) + .Map(list => list.All(mc => mc.Name != Name)); + + if (needToAdd) + { + var data = new CreateSimpleMediaCollection(Name); + await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken); + _logger.LogInformation("Successfully created media collection {MediaCollection}", Name); + } + else + { + _logger.LogInformation("Media collection {MediaCollection} is already present", Name); + } + + return unit; + } + } +} diff --git a/ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionMediaItemsCommand.cs b/ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionMediaItemsCommand.cs new file mode 100644 index 000000000..259be438f --- /dev/null +++ b/ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionMediaItemsCommand.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CliFx; +using CliFx.Attributes; +using ErsatzTV.Api.Sdk.Api; +using ErsatzTV.Api.Sdk.Model; +using LanguageExt; +using LanguageExt.Common; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; + +namespace ErsatzTV.CommandLine.Commands.MediaCollections +{ + [Command("collection add-items", Description = "Ensure media collection exists and contains requested media items")] + public class MediaCollectionMediaItemsCommand : MediaItemCommandBase + { + private readonly ILogger _logger; + private readonly string _serverUrl; + + public MediaCollectionMediaItemsCommand( + IConfiguration configuration, + ILogger logger) + { + _logger = logger; + _serverUrl = configuration["ServerUrl"]; + } + + [CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")] + public string Name { get; set; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + CancellationToken cancellationToken = console.GetCancellationToken(); + + Either> maybeFileNames = await GetFileNames(); + await maybeFileNames.Match( + allFiles => SynchronizeMediaItemsToCollection(cancellationToken, allFiles), + error => + { + _logger.LogError("{Error}", error.Message); + return Task.CompletedTask; + }); + } + catch (Exception ex) + { + _logger.LogError("Unable to synchronize media items to media collection: {Error}", ex.Message); + } + } + + private async Task SynchronizeMediaItemsToCollection(CancellationToken cancellationToken, List allFiles) + { + Either result = await GetMediaSourceIdAsync(cancellationToken) + .BindAsync(mediaSourceId => SynchronizeMediaItemsAsync(mediaSourceId, allFiles, cancellationToken)) + .BindAsync(mediaItemIds => SynchronizeMediaItemsToCollectionAsync(mediaItemIds, cancellationToken)); + + result.Match( + _ => _logger.LogInformation( + "Successfully synchronized {Count} media items to media collection {MediaCollection}", + allFiles.Count, + Name), + error => _logger.LogError( + "Unable to synchronize media items to media collection: {Error}", + error.Message)); + } + + private async Task> GetMediaSourceIdAsync(CancellationToken cancellationToken) + { + var mediaSourcesApi = new MediaSourcesApi(_serverUrl); + List allMediaSources = + await mediaSourcesApi.ApiMediaSourcesGetAsync(cancellationToken); + Option maybeLocalMediaSource = + allMediaSources.SingleOrDefault(cs => cs.SourceType == MediaSourceType.Local); + return maybeLocalMediaSource.Match>( + mediaSource => mediaSource.Id, + () => Error.New("Unable to find local media source")); + } + + private async Task>> SynchronizeMediaItemsAsync( + int mediaSourceId, + ICollection fileNames, + CancellationToken cancellationToken) + { + var mediaItemsApi = new MediaItemsApi(_serverUrl); + List allMediaItems = await mediaItemsApi.ApiMediaItemsGetAsync(cancellationToken); + var missingMediaItems = fileNames.Where(f => allMediaItems.All(c => c.Path != f)) + .Map(f => new CreateMediaItem(mediaSourceId, f)) + .ToList(); + + var addedIds = new List(); + foreach (CreateMediaItem mediaItem in missingMediaItems) + { + _logger.LogInformation("Adding media item {Path}", mediaItem.Path); + addedIds.Add(await mediaItemsApi.ApiMediaItemsPostAsync(mediaItem, cancellationToken).Map(vm => vm.Id)); + } + + IEnumerable knownIds = allMediaItems.Where(c => fileNames.Contains(c.Path)).Map(c => c.Id); + + return knownIds.Concat(addedIds).ToList(); + } + + private async Task> SynchronizeMediaItemsToCollectionAsync( + List mediaItemIds, + CancellationToken cancellationToken) => + await EnsureMediaCollectionExistsAsync(cancellationToken) + .BindAsync( + mediaSourceId => SynchronizeMediaCollectionAsync(mediaSourceId, mediaItemIds, cancellationToken)); + + private async Task> EnsureMediaCollectionExistsAsync(CancellationToken cancellationToken) + { + var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl); + Option maybeExisting = await mediaCollectionsApi + .ApiMediaCollectionsGetAsync(cancellationToken) + .Map(list => list.SingleOrDefault(mc => mc.Name == Name)); + return await maybeExisting.Match( + existing => Task.FromResult(existing.Id), + async () => + { + var data = new CreateSimpleMediaCollection(Name); + return await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken) + .Map(vm => vm.Id); + }); + } + + private async Task> SynchronizeMediaCollectionAsync( + int mediaCollectionId, + List mediaItemIds, + CancellationToken cancellationToken) + { + var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl); + await mediaCollectionsApi.ApiMediaCollectionsIdItemsPutAsync( + mediaCollectionId, + mediaItemIds, + cancellationToken); + return unit; + } + } +} diff --git a/ErsatzTV.CommandLine/Commands/MediaItemCommandBase.cs b/ErsatzTV.CommandLine/Commands/MediaItemCommandBase.cs new file mode 100644 index 000000000..850f73dc9 --- /dev/null +++ b/ErsatzTV.CommandLine/Commands/MediaItemCommandBase.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using CliFx; +using CliFx.Attributes; +using LanguageExt; +using LanguageExt.Common; + +namespace ErsatzTV.CommandLine.Commands +{ + public abstract class MediaItemCommandBase : ICommand + { + [CommandOption("folder", 'f', Description = "Folder to search for media items")] + public string Folder { get; set; } + + [CommandOption("pattern", 'p', Description = "File search pattern")] + public string SearchPattern { get; set; } + + public abstract ValueTask ExecuteAsync(IConsole console); + + protected async Task>> GetFileNames() + { + if (Console.IsInputRedirected) + { + await using Stream standardInput = Console.OpenStandardInput(); + using var streamReader = new StreamReader(standardInput); + string input = await streamReader.ReadToEndAsync(); + return input.Trim().Split("\n").Map(s => s.Trim()).ToList(); + } + + if (string.IsNullOrWhiteSpace(Folder) || string.IsNullOrWhiteSpace(SearchPattern)) + { + return Error.New( + "--folder and --pattern are required when file names are not passed on standard input"); + } + + return Directory.GetFiles(Folder, SearchPattern, SearchOption.AllDirectories).ToList(); + } + } +} diff --git a/ErsatzTV.CommandLine/Commands/MediaItemsCommand.cs b/ErsatzTV.CommandLine/Commands/MediaItemsCommand.cs new file mode 100644 index 000000000..10576a1b6 --- /dev/null +++ b/ErsatzTV.CommandLine/Commands/MediaItemsCommand.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CliFx; +using CliFx.Attributes; +using ErsatzTV.Api.Sdk.Api; +using ErsatzTV.Api.Sdk.Model; +using LanguageExt; +using LanguageExt.Common; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; + +namespace ErsatzTV.CommandLine.Commands +{ + [Command("items", Description = "Ensure media items exist")] + public class MediaItemsCommand : MediaItemCommandBase + { + private readonly ILogger _logger; + private readonly string _serverUrl; + + public MediaItemsCommand(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _serverUrl = configuration["ServerUrl"]; + } + + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + CancellationToken cancellationToken = console.GetCancellationToken(); + + Either> maybeFileNames = await GetFileNames(); + await maybeFileNames.Match( + allFiles => SynchronizeMediaItems(cancellationToken, allFiles), + error => + { + _logger.LogError("{Error}", error.Message); + return Task.CompletedTask; + }); + } + catch (Exception ex) + { + _logger.LogError("Unable to synchronize media items: {Error}", ex.Message); + } + } + + private async Task SynchronizeMediaItems(CancellationToken cancellationToken, List allFiles) + { + Either result = await GetMediaSourceId(cancellationToken) + .BindAsync( + contentSourceId => PostMediaItems( + contentSourceId, + allFiles, + cancellationToken)); + + result.Match( + _ => _logger.LogInformation( + "Successfully synchronized {Count} media items", + allFiles.Count), + error => _logger.LogError("Unable to synchronize media items: {Error}", error.Message)); + } + + private async Task> GetMediaSourceId(CancellationToken cancellationToken) + { + var mediaSourcesApi = new MediaSourcesApi(_serverUrl); + List allMediaSources = + await mediaSourcesApi.ApiMediaSourcesGetAsync(cancellationToken); + Option maybeLocalMediaSource = + allMediaSources.SingleOrDefault(cs => cs.SourceType == MediaSourceType.Local); + return maybeLocalMediaSource.Match>( + mediaSource => mediaSource.Id, + () => Error.New("Unable to find local media source")); + } + + private async Task> PostMediaItems( + int mediaSourceId, + ICollection fileNames, + CancellationToken cancellationToken) + { + var mediaItemsApi = new MediaItemsApi(_serverUrl); + List allContent = await mediaItemsApi.ApiMediaItemsGetAsync(cancellationToken); + var missingMediaItems = fileNames.Where(f => allContent.All(c => c.Path != f)) + .Map(f => new CreateMediaItem(mediaSourceId, f)) + .ToList(); + + foreach (CreateMediaItem mediaItem in missingMediaItems) + { + _logger.LogInformation("Adding media item {Path}", mediaItem.Path); + await mediaItemsApi.ApiMediaItemsPostAsync(mediaItem, cancellationToken); + } + + return unit; + } + } +} diff --git a/ErsatzTV.CommandLine/Commands/PlayoutCommand.cs b/ErsatzTV.CommandLine/Commands/PlayoutCommand.cs new file mode 100644 index 000000000..5d387a734 --- /dev/null +++ b/ErsatzTV.CommandLine/Commands/PlayoutCommand.cs @@ -0,0 +1,107 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CliFx; +using CliFx.Attributes; +using ErsatzTV.Api.Sdk.Api; +using ErsatzTV.Api.Sdk.Model; +using LanguageExt; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.CommandLine.Commands +{ + [Command("playout build", Description = "Builds a playout with the requested channel and schedule")] + public class PlayoutCommand : ICommand + { + private readonly ILogger _logger; + private readonly string _serverUrl; + + public PlayoutCommand(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _serverUrl = configuration["ServerUrl"]; + } + + [CommandParameter(0, Name = "channel-number", Description = "The channel number")] + public int ChannelNumber { get; set; } + + [CommandParameter(1, Name = "schedule-name", Description = "The schedule name")] + public string ScheduleName { get; set; } + + // [Option("--type ")] + // [Required] + // public ProgramSchedulePlayoutType PlayoutType { get; set; } + + public async ValueTask ExecuteAsync(IConsole console) + { + try + { + CancellationToken cancellationToken = console.GetCancellationToken(); + + var channelsApi = new ChannelsApi(_serverUrl); + Option maybeChannel = await channelsApi.ApiChannelsGetAsync(cancellationToken) + .Map(list => list.SingleOrDefault(c => c.Number == ChannelNumber)); + + await maybeChannel.Match( + channel => BuildPlayout(cancellationToken, channel), + () => + { + _logger.LogError("Unable to locate channel number {ChannelNumber}", ChannelNumber); + return ValueTask.CompletedTask; + }); + } + catch (Exception ex) + { + _logger.LogError("Unable to build playout: {Error}", ex.Message); + } + } + + private async ValueTask BuildPlayout(CancellationToken cancellationToken, ChannelViewModel channel) + { + var programScheduleApi = new ProgramScheduleApi(_serverUrl); + Option maybeSchedule = await programScheduleApi + .ApiSchedulesGetAsync(cancellationToken) + .Map(list => list.SingleOrDefault(s => s.Name == ScheduleName)); + + await maybeSchedule.Match( + schedule => SynchronizePlayoutAsync(channel.Id, schedule.Id, cancellationToken), + () => + { + _logger.LogError("Unable to locate schedule {Schedule}", ScheduleName); + return ValueTask.CompletedTask; + }); + } + + private async ValueTask SynchronizePlayoutAsync( + int channelId, + int scheduleId, + CancellationToken cancellationToken) + { + var playoutApi = new PlayoutApi(_serverUrl); + Option maybeExisting = await playoutApi.ApiPlayoutsGetAsync(cancellationToken) + .Map(list => list.SingleOrDefault(p => p.Channel.Id == channelId)); + await maybeExisting.Match( + existing => + { + var data = new UpdatePlayout(existing.Id, channelId, scheduleId, ProgramSchedulePlayoutType.Flood); + if (existing.Channel.Id != data.ChannelId || + existing.ProgramSchedule.Id != data.ProgramScheduleId || + existing.ProgramSchedulePlayoutType != data.ProgramSchedulePlayoutType) + { + return playoutApi.ApiPlayoutsPatchAsync(data, cancellationToken); + } + + return Task.CompletedTask; + }, + () => + { + var data = new CreatePlayout(channelId, scheduleId, ProgramSchedulePlayoutType.Flood); + return playoutApi.ApiPlayoutsPostAsync(data, cancellationToken); + }); + + _logger.LogInformation("Successfully built playout for schedule {Schedule}", ScheduleName); + } + } +} diff --git a/ErsatzTV.CommandLine/Commands/Schedules/ScheduleAddItemCommand.cs b/ErsatzTV.CommandLine/Commands/Schedules/ScheduleAddItemCommand.cs new file mode 100644 index 000000000..1a76f9018 --- /dev/null +++ b/ErsatzTV.CommandLine/Commands/Schedules/ScheduleAddItemCommand.cs @@ -0,0 +1,140 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CliFx; +using CliFx.Attributes; +using ErsatzTV.Api.Sdk.Api; +using ErsatzTV.Api.Sdk.Model; +using LanguageExt; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.CommandLine.Commands.Schedules +{ + [Command("schedule add-item", Description = "Adds an item to the end of a schedule")] + public class ScheduleAddItemCommand : ICommand + { + private readonly ILogger _logger; + private readonly string _serverUrl; + + public ScheduleAddItemCommand(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _serverUrl = configuration["ServerUrl"]; + } + + [CommandParameter(0, Name = "schedule-name", Description = "The schedule name")] + public string ScheduleName { get; set; } + + [CommandParameter(1, Name = "collection-name", Description = "The media collection name")] + public string CollectionName { get; set; } + + // [CommandParameter(2, Description = "The collection playback order")] + // public PlaybackOrder Order { get; set; } + + [CommandOption("start-type", 's', Description = "The playout start type")] + public StartType StartType { get; set; } = StartType.Dynamic; + + [CommandOption("start-time", 't', Description = "The playout start time (of day)")] + public string StartTime { get; set; } = null; + + [CommandOption("playout-mode", 'm', Description = "The playout mode")] + public PlayoutMode PlayoutMode { get; set; } = PlayoutMode.Flood; + + [CommandOption( + "multiple-count", + 'c', + Description = "How many items to play from the collection (for Multiple playout mode)")] + public int? MultipleCount { get; set; } = null; + + [CommandOption( + "playout-duration", + 'd', + Description = "How long to play items from the collection (for Duration playout mode)")] + public string PlayoutDuration { get; set; } = null; + + [CommandOption( + "offline-tail", + 'o', + Description = + "Whether to remain offline for the entire duration, or to start the next item immediately (for Duration playout mode)")] + public bool? OfflineTail { get; set; } = null; + + public async ValueTask ExecuteAsync(IConsole console) + { + try + { + CancellationToken cancellationToken = console.GetCancellationToken(); + + Option maybeSchedule = await GetSchedule(cancellationToken); + await maybeSchedule.Match( + programSchedule => AddItemToSchedule(cancellationToken, programSchedule), + () => + { + _logger.LogError("Unable to locate schedule {Schedule}", ScheduleName); + return ValueTask.CompletedTask; + }); + } + catch (Exception ex) + { + _logger.LogError("Unable to add item to schedule: {Error}", ex.Message); + } + } + + private async ValueTask AddItemToSchedule( + CancellationToken cancellationToken, + ProgramScheduleViewModel programSchedule) + { + var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl); + Option maybeMediaCollection = await mediaCollectionsApi + .ApiMediaCollectionsGetAsync(cancellationToken) + .Map(list => list.SingleOrDefault(mc => mc.Name == CollectionName)); + + await maybeMediaCollection.Match( + collection => + AddScheduleItem(programSchedule.Id, collection.Id, cancellationToken), + () => + { + _logger.LogError( + "Unable to locate media collection {MediaCollection}", + CollectionName); + return Task.CompletedTask; + }); + } + + private async Task> GetSchedule(CancellationToken cancellationToken) + { + var programScheduleApi = new ProgramScheduleApi(_serverUrl); + return await programScheduleApi.ApiSchedulesGetAsync(cancellationToken) + .Map(list => list.SingleOrDefault(schedule => schedule.Name == ScheduleName)); + } + + private async Task AddScheduleItem( + int programScheduleId, + int mediaCollectionId, + CancellationToken cancellationToken) + { + var programScheduleApi = new ProgramScheduleApi(_serverUrl); + + var request = new AddProgramScheduleItem + { + ProgramScheduleId = programScheduleId, + StartType = StartType, + StartTime = StartTime, + PlayoutMode = PlayoutMode, + MediaCollectionId = mediaCollectionId, + PlayoutDuration = PlayoutDuration, + MultipleCount = MultipleCount, + OfflineTail = OfflineTail + }; + + await programScheduleApi.ApiSchedulesItemsAddPostAsync(request, cancellationToken); + + _logger.LogInformation( + "Collection {Collection} has been added to schedule {Schedule}", + CollectionName, + ScheduleName); + } + } +} diff --git a/ErsatzTV.CommandLine/Commands/Schedules/ScheduleCreateCommand.cs b/ErsatzTV.CommandLine/Commands/Schedules/ScheduleCreateCommand.cs new file mode 100644 index 000000000..ab9beeacd --- /dev/null +++ b/ErsatzTV.CommandLine/Commands/Schedules/ScheduleCreateCommand.cs @@ -0,0 +1,86 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CliFx; +using CliFx.Attributes; +using ErsatzTV.Api.Sdk.Api; +using ErsatzTV.Api.Sdk.Model; +using LanguageExt; +using LanguageExt.Common; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; + +namespace ErsatzTV.CommandLine.Commands.Schedules +{ + [Command("schedule create", Description = "Creates a new schedule")] + public class ScheduleCreateCommand : ICommand + { + private readonly ILogger _logger; + private readonly string _serverUrl; + + public ScheduleCreateCommand(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _serverUrl = configuration["ServerUrl"]; + } + + [CommandParameter(0, Name = "schedule-name", Description = "The schedule name")] + public string Name { get; set; } + + [CommandParameter(1, Name = "playback-order", Description = "The collection playback order")] + public PlaybackOrder Order { get; set; } + + [CommandOption("reset", Description = "Resets the schedule to contain no items")] + public bool Reset { get; set; } + + public async ValueTask ExecuteAsync(IConsole console) + { + try + { + CancellationToken cancellationToken = console.GetCancellationToken(); + + Either result = await EnsureScheduleExistsAsync(cancellationToken); + result.IfLeft(error => _logger.LogError("Unable to create schedule: {Error}", error.Message)); + } + catch (Exception ex) + { + _logger.LogError("Unable to create schedule: {Error}", ex.Message); + } + } + + private async Task> EnsureScheduleExistsAsync(CancellationToken cancellationToken) + { + var programScheduleApi = new ProgramScheduleApi(_serverUrl); + + Option maybeExisting = await programScheduleApi + .ApiSchedulesGetAsync(cancellationToken) + .Map(list => list.SingleOrDefault(schedule => schedule.Name == Name)); + + await maybeExisting.Match( + existing => + { + // TODO: update playback order if changed? + _logger.LogInformation("Schedule {Schedule} is already present", Name); + + if (Reset) + { + return programScheduleApi + .ApiSchedulesProgramScheduleIdItemsDeleteAsync(existing.Id, cancellationToken) + .Iter(_ => _logger.LogInformation("Successfully reset schedule {Schedule}", Name)); + } + + return Task.CompletedTask; + }, + () => + { + var data = new CreateProgramSchedule(Name, Order); + return programScheduleApi.ApiSchedulesPostAsync(data, cancellationToken) + .Iter(_ => _logger.LogInformation("Successfully created schedule {Schedule}", Name)); + }); + + return unit; + } + } +} diff --git a/ErsatzTV.CommandLine/Config.cs b/ErsatzTV.CommandLine/Config.cs new file mode 100644 index 000000000..f0235d161 --- /dev/null +++ b/ErsatzTV.CommandLine/Config.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.CommandLine +{ + public class Config + { + public string ServerUrl { get; set; } + } +} diff --git a/ErsatzTV.CommandLine/DesiredResolution.cs b/ErsatzTV.CommandLine/DesiredResolution.cs new file mode 100644 index 000000000..2a5e2a1bc --- /dev/null +++ b/ErsatzTV.CommandLine/DesiredResolution.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.CommandLine +{ + public enum DesiredResolution + { + W720H480 = 1, + W1280H720 = 2, + W1920H1080 = 3, + W3840H2160 = 4 + } +} diff --git a/ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj b/ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj new file mode 100644 index 000000000..fdccddf6a --- /dev/null +++ b/ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj @@ -0,0 +1,35 @@ + + + + Exe + net5.0 + ersatztv-cli + 9 + 0.0.1 + 0.0.1 + + + + + + + + + + + + + + + + + + + + + + ..\..\..\..\..\..\usr\share\dotnet\packs\Microsoft.AspNetCore.App.Ref\5.0.0\ref\net5.0\Microsoft.Extensions.Hosting.Abstractions.dll + + + + diff --git a/ErsatzTV.CommandLine/Program.cs b/ErsatzTV.CommandLine/Program.cs new file mode 100644 index 000000000..dd6547f55 --- /dev/null +++ b/ErsatzTV.CommandLine/Program.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using CliFx; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; +using Serilog.Events; + +namespace ErsatzTV.CommandLine +{ + public class Program + { + public static async Task Main(string[] args) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + + IHost host = CreateHostBuilder(args).Build(); + try + { + return await new CliApplicationBuilder() + .AddCommandsFromThisAssembly() + .UseTypeActivator(host.Services.GetService) + .Build() + .RunAsync(args); + } + catch (Exception ex) + { + Log.Fatal(ex, "Host terminated unexpectedly"); + return 1; + } + finally + { + Log.CloseAndFlush(); + } + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureServices( + (_, services) => + { + services.AddSingleton(); + IEnumerable typesThatImplementICommand = typeof(Program).Assembly.GetTypes() + .Where(x => typeof(ICommand).IsAssignableFrom(x)) + .Where(x => !x.IsAbstract); + foreach (Type t in typesThatImplementICommand) + { + services.AddTransient(t); + } + }) + .ConfigureAppConfiguration( + (_, configuration) => + { + configuration.Sources.Clear(); + + string configFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ersatztv"); + + configuration.SetBasePath(configFolder); + configuration.AddJsonFile("cli.json", true, true); + }) + .UseSerilog() + .UseConsoleLifetime(); + } +} diff --git a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj new file mode 100644 index 000000000..fb6f2c25f --- /dev/null +++ b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj @@ -0,0 +1,23 @@ + + + + net5.0 + + + + + + + + + + + + + + + + + + + diff --git a/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs new file mode 100644 index 000000000..85ae5fd1e --- /dev/null +++ b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs @@ -0,0 +1,758 @@ +using System; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.FFmpeg; +using FluentAssertions; +using NUnit.Framework; + +namespace ErsatzTV.Core.Tests.FFmpeg +{ + public class FFmpegPlaybackSettingsCalculatorTests + { + public class CalculateSettings + { + private readonly FFmpegPlaybackSettingsCalculator _calculator; + + public CalculateSettings() => _calculator = new FFmpegPlaybackSettingsCalculator(); + + private static PlayoutItem EmptyPlayoutItem() => + new() + { + MediaItem = new MediaItem + { + Metadata = new MediaMetadata() + } + }; + + [Test] + public void Should_UseSpecifiedThreadCount_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with { ThreadCount = 7 }; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + EmptyPlayoutItem(), + DateTimeOffset.Now); + + actual.ThreadCount.Should().Be(7); + } + + [Test] + public void Should_UseSpecifiedThreadCount_ForHttpLiveStreaming() + { + FFmpegProfile ffmpegProfile = TestProfile() with { ThreadCount = 7 }; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.HttpLiveStreaming, + ffmpegProfile, + EmptyPlayoutItem(), + DateTimeOffset.Now); + + actual.ThreadCount.Should().Be(7); + } + + [Test] + public void Should_SetFormatFlags_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile(); + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + EmptyPlayoutItem(), + DateTimeOffset.Now); + + string[] expected = { "+genpts", "+discardcorrupt", "+igndts" }; + actual.FormatFlags.Count.Should().Be(expected.Length); + actual.FormatFlags.Should().Contain(expected); + } + + [Test] + public void Should_SetFormatFlags_ForHttpLiveStreaming() + { + FFmpegProfile ffmpegProfile = TestProfile(); + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.HttpLiveStreaming, + ffmpegProfile, + EmptyPlayoutItem(), + DateTimeOffset.Now); + + string[] expected = { "+genpts", "+discardcorrupt", "+igndts" }; + actual.FormatFlags.Count.Should().Be(expected.Length); + actual.FormatFlags.Should().Contain(expected); + } + + [Test] + public void Should_SetRealtime_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile(); + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + EmptyPlayoutItem(), + DateTimeOffset.Now); + + actual.RealtimeOutput.Should().BeTrue(); + } + + [Test] + public void Should_SetRealtime_ForHttpLiveStreaming() + { + FFmpegProfile ffmpegProfile = TestProfile(); + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.HttpLiveStreaming, + ffmpegProfile, + EmptyPlayoutItem(), + DateTimeOffset.Now); + + actual.RealtimeOutput.Should().BeTrue(); + } + + [Test] + public void Should_SetStreamSeek_When_PlaybackIsLate_ForTransportStream() + { + DateTimeOffset now = DateTimeOffset.Now; + + FFmpegProfile ffmpegProfile = TestProfile(); + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.Start = now; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + now.AddMinutes(5)); + + actual.StreamSeek.IsSome.Should().BeTrue(); + actual.StreamSeek.IfNone(TimeSpan.Zero).Should().Be(TimeSpan.FromMinutes(5)); + } + + [Test] + public void Should_SetStreamSeek_When_PlaybackIsLate_ForHttpLiveStreaming() + { + DateTimeOffset now = DateTimeOffset.Now; + + FFmpegProfile ffmpegProfile = TestProfile(); + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.Start = now; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.HttpLiveStreaming, + ffmpegProfile, + playoutItem, + now.AddMinutes(5)); + + actual.StreamSeek.IsSome.Should().BeTrue(); + actual.StreamSeek.IfNone(TimeSpan.Zero).Should().Be(TimeSpan.FromMinutes(5)); + } + + [Test] + public void ShouldNot_SetScaledSize_When_NotNormalizingResolution_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with { NormalizeResolution = false }; + PlayoutItem playoutItem = EmptyPlayoutItem(); + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + } + + [Test] + public void ShouldNot_SetScaledSize_When_ContentIsCorrectSize_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 } + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1920; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + } + + [Test] + public void ShouldNot_SetScaledSize_When_ScaledSizeWouldEqualContentSize_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 } + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1918; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + } + + [Test] + public void ShouldNot_PadToDesiredResolution_When_ContentIsCorrectSize_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 } + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1920; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + actual.PadToDesiredResolution.Should().BeFalse(); + } + + [Test] + public void Should_PadToDesiredResolution_When_UnscaledContentIsUnderSized_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 } + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1918; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + actual.PadToDesiredResolution.Should().BeTrue(); + } + + [Test] + public void Should_NotPadToDesiredResolution_When_UnscaledContentIsUnderSized_ForHttpLiveStreaming() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 } + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1918; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.HttpLiveStreaming, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + actual.PadToDesiredResolution.Should().BeFalse(); + } + + [Test] + public void Should_SetDesiredVideoCodec_When_ContentIsPadded_ForTransportStream() + { + var ffmpegProfile = new FFmpegProfile + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 }, + NormalizeVideoCodec = false, + VideoCodec = "testCodec" + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1918; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + actual.PadToDesiredResolution.Should().BeTrue(); + actual.VideoCodec.Should().Be("testCodec"); + } + + [Test] + public void + Should_SetDesiredVideoCodec_When_ContentIsCorrectSize_And_NormalizingWrongCodec_ForTransportStream() + { + var ffmpegProfile = new FFmpegProfile + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 }, + NormalizeVideoCodec = true, + VideoCodec = "testCodec" + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1920; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + actual.PadToDesiredResolution.Should().BeFalse(); + actual.VideoCodec.Should().Be("testCodec"); + } + + [Test] + public void + Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NormalizingWrongCodec_ForHttpLiveStreaming() + { + var ffmpegProfile = new FFmpegProfile + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 }, + NormalizeVideoCodec = true, + VideoCodec = "testCodec" + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1920; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.HttpLiveStreaming, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + actual.PadToDesiredResolution.Should().BeFalse(); + actual.VideoCodec.Should().Be("copy"); + } + + [Test] + public void Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_CorrectCodec_ForTransportStream() + { + var ffmpegProfile = new FFmpegProfile + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 }, + NormalizeVideoCodec = true, + VideoCodec = "libx264" + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1920; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Metadata.VideoCodec = "libx264"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + actual.PadToDesiredResolution.Should().BeFalse(); + actual.VideoCodec.Should().Be("copy"); + } + + [Test] + public void + Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NotNormalizingWrongCodec_ForTransportStream() + { + var ffmpegProfile = new FFmpegProfile + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 }, + NormalizeVideoCodec = false, + VideoCodec = "libx264" + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1920; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + actual.PadToDesiredResolution.Should().BeFalse(); + actual.VideoCodec.Should().Be("copy"); + } + + [Test] + public void Should_SetVideoBitrate_When_ContentIsPadded_ForTransportStream() + { + var ffmpegProfile = new FFmpegProfile + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 }, + NormalizeVideoCodec = false, + VideoBitrate = 2525 + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1918; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + actual.PadToDesiredResolution.Should().BeTrue(); + actual.VideoBitrate.IfNone(0).Should().Be(2525); + } + + [Test] + public void Should_SetVideoBitrate_When_ContentIsCorrectSize_And_NormalizingWrongCodec_ForTransportStream() + { + var ffmpegProfile = new FFmpegProfile + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 }, + NormalizeVideoCodec = true, + VideoBitrate = 2525 + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1920; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + actual.PadToDesiredResolution.Should().BeFalse(); + actual.VideoBitrate.IfNone(0).Should().Be(2525); + } + + [Test] + public void Should_SetVideoBufferSize_When_ContentIsPadded_ForTransportStream() + { + var ffmpegProfile = new FFmpegProfile + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 }, + NormalizeVideoCodec = false, + VideoBufferSize = 2525 + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1918; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + actual.PadToDesiredResolution.Should().BeTrue(); + actual.VideoBufferSize.IfNone(0).Should().Be(2525); + } + + [Test] + public void + Should_SetVideoBufferSize_When_ContentIsCorrectSize_And_NormalizingWrongCodec_ForTransportStream() + { + var ffmpegProfile = new FFmpegProfile + { + NormalizeResolution = true, + Resolution = new Resolution { Width = 1920, Height = 1080 }, + NormalizeVideoCodec = true, + VideoBufferSize = 2525 + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.Width = 1920; + playoutItem.MediaItem.Metadata.Height = 1080; + playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + actual.PadToDesiredResolution.Should().BeFalse(); + actual.VideoBufferSize.IfNone(0).Should().Be(2525); + } + + [Test] + public void Should_SetCopyAudioCodec_When_CorrectCodec_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeAudioCodec = true, + AudioCodec = "aac" + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.AudioCodec = "aac"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.AudioCodec.Should().Be("copy"); + } + + [Test] + public void Should_SetCopyAudioCodec_When_NotNormalizingWrongCodec_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeAudioCodec = false, + AudioCodec = "aac" + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.AudioCodec.Should().Be("copy"); + } + + [Test] + public void Should_SetDesiredAudioCodec_When_NormalizingWrongCodec_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeAudioCodec = true, + AudioCodec = "aac" + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.AudioCodec.Should().Be("aac"); + } + + [Test] + public void Should_SetCopyAudioCodec_When_NormalizingWrongCodec_ForHttpLiveStreaming() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeAudioCodec = true, + AudioCodec = "aac" + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.HttpLiveStreaming, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.AudioCodec.Should().Be("copy"); + } + + [Test] + public void Should_SetAudioBitrate_When_NormalizingWrongCodec_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeAudioCodec = true, + AudioBitrate = 2424 + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.AudioBitrate.IfNone(0).Should().Be(2424); + } + + [Test] + public void Should_SetAudioBufferSize_When_NormalizingWrongCodec_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeAudioCodec = true, + AudioBufferSize = 2424 + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.AudioBufferSize.IfNone(0).Should().Be(2424); + } + + [Test] + public void ShouldNot_SetAudioChannels_When_CorrectCodec_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeAudioCodec = true, + NormalizeAudio = true, + AudioCodec = "ac3", + AudioChannels = 6 + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.AudioChannels.IsNone.Should().BeTrue(); + } + + [Test] + public void ShouldNot_SetAudioSampleRate_When_CorrectCodec_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeAudioCodec = true, + NormalizeAudio = true, + AudioCodec = "ac3", + AudioSampleRate = 48 + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.AudioSampleRate.IsNone.Should().BeTrue(); + } + + [Test] + public void Should_SetAudioChannels_When_NormalizingWrongCodecAndAudio_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeAudioCodec = true, + NormalizeAudio = true, + AudioChannels = 6 + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.AudioChannels.IfNone(0).Should().Be(6); + } + + [Test] + public void Should_SetAudioSampleRate_When_NormalizingWrongCodecAndAudio_ForTransportStream() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeAudioCodec = true, + NormalizeAudio = true, + AudioSampleRate = 48 + }; + + PlayoutItem playoutItem = EmptyPlayoutItem(); + playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + playoutItem, + DateTimeOffset.Now); + + actual.AudioSampleRate.IfNone(0).Should().Be(48); + } + + private FFmpegProfile TestProfile() => + new() { Resolution = new Resolution { Width = 1920, Height = 1080 } }; + } + } +} diff --git a/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs new file mode 100644 index 000000000..ecf65ee2a --- /dev/null +++ b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.AggregateModels; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Tests.Fakes +{ + public class FakeMediaCollectionRepository : IMediaCollectionRepository + { + private readonly Map> _data; + + public FakeMediaCollectionRepository(Map> data) => _data = data; + + public Task Add(SimpleMediaCollection collection) => throw new NotSupportedException(); + + public Task> Get(int id) => throw new NotSupportedException(); + + public Task> GetSimpleMediaCollection(int id) => + throw new NotSupportedException(); + + public Task> GetTelevisionMediaCollection(int id) => + throw new NotSupportedException(); + + public Task> GetSimpleMediaCollections() => throw new NotSupportedException(); + + public Task> GetAll() => throw new NotSupportedException(); + + public Task> GetSummaries(string searchString) => + throw new NotSupportedException(); + + public Task>> GetItems(int id) => Some(_data[id]).AsTask(); + + public Task>> GetSimpleMediaCollectionItems(int id) => + throw new NotSupportedException(); + + public Task>> GetTelevisionMediaCollectionItems(int id) => + throw new NotSupportedException(); + + public Task Update(SimpleMediaCollection collection) => throw new NotSupportedException(); + + public Task InsertOrIgnore(TelevisionMediaCollection collection) => throw new NotSupportedException(); + + public Task ReplaceItems(int collectionId, List mediaItems) => + throw new NotSupportedException(); + + public Task Delete(int mediaCollectionId) => throw new NotSupportedException(); + public Task DeleteEmptyTelevisionCollections() => throw new NotSupportedException(); + } +} diff --git a/ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs b/ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs new file mode 100644 index 000000000..b4fd5df3e --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using FluentAssertions; +using NUnit.Framework; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Tests.Scheduling +{ + public class ChronologicalContentTests + { + [Test] + public void Episodes_Should_Sort_By_Aired() + { + List contents = Episodes(10); + var state = new MediaCollectionEnumeratorState(); + + var chronologicalContent = new ChronologicalMediaCollectionEnumerator(contents, state); + + for (var i = 1; i <= 10; i++) + { + chronologicalContent.Current.IsSome.Should().BeTrue(); + chronologicalContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i); + chronologicalContent.MoveNext(); + } + } + + [Test] + public void State_Index_Should_Increment() + { + List contents = Episodes(10); + var state = new MediaCollectionEnumeratorState(); + + var chronologicalContent = new ChronologicalMediaCollectionEnumerator(contents, state); + + for (var i = 0; i < 10; i++) + { + chronologicalContent.State.Index.Should().Be(i % 10); + chronologicalContent.MoveNext(); + } + } + + [Test] + public void State_Should_Impact_Iterator_Start() + { + List contents = Episodes(10); + var state = new MediaCollectionEnumeratorState { Index = 5 }; + + var chronologicalContent = new ChronologicalMediaCollectionEnumerator(contents, state); + + for (var i = 6; i <= 10; i++) + { + chronologicalContent.Current.IsSome.Should().BeTrue(); + chronologicalContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i); + chronologicalContent.State.Index.Should().Be(i - 1); + chronologicalContent.MoveNext(); + } + } + + [Test] + public void Peek_Should_Not_Impact_Current_Or_Wrapping() + { + List contents = Episodes(10); + var state = new MediaCollectionEnumeratorState(); + + var chronologicalContent = new ChronologicalMediaCollectionEnumerator(contents, state); + + for (var i = 0; i < 100; i++) + { + chronologicalContent.Current.IsSome.Should().BeTrue(); + + chronologicalContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i % 10 + 1); + chronologicalContent.Peek.Map(x => x.Id).IfNone(-1).Should().Be((i + 1) % 10 + 1); + chronologicalContent.Peek.Map(x => x.Id).IfNone(-1).Should().Be((i + 1) % 10 + 1); + + chronologicalContent.MoveNext(); + } + } + + private static List Episodes(int count) => + Range(1, count).Map( + i => new MediaItem + { + Id = i, + Metadata = new MediaMetadata + { + MediaType = MediaType.TvShow, Aired = new DateTime(2020, 1, i) + } + }) + .Reverse() + .ToList(); + } +} diff --git a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs new file mode 100644 index 000000000..ee477bbb3 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs @@ -0,0 +1,547 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Core.Tests.Fakes; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using Serilog; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Tests.Scheduling +{ + public class PlayoutBuilderTests + { + private readonly ILogger _logger; + + public PlayoutBuilderTests() + { + if (Log.Logger.GetType().FullName == "Serilog.Core.Pipeline.SilentLogger") + { + Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().CreateLogger(); + Log.Logger.Debug( + "Logger is not configured. Either this is a unit test or you have to configure the logger"); + } + + ServiceProvider serviceProvider = new ServiceCollection() + .AddLogging(builder => builder.AddSerilog(dispose: true)) + .BuildServiceProvider(); + + ILoggerFactory factory = serviceProvider.GetService(); + + _logger = factory.CreateLogger(); + } + + [Test] + public async Task InitialFlood_Should_StartAtMidnight() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(6), DateTime.Today) + }; + + (PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Random); + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.BuildPlayoutItems(playout, start, finish); + + result.Items.Count.Should().Be(1); + result.Items.Head().Start.TimeOfDay.Should().Be(TimeSpan.Zero); + result.Items.Head().Finish.TimeOfDay.Should().Be(TimeSpan.FromHours(6)); + } + + [Test] + public async Task InitialFlood_Should_StartAtMidnight_With_LateStart() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(6), DateTime.Today) + }; + + (PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Random); + DateTimeOffset start = HoursAfterMidnight(1); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.BuildPlayoutItems(playout, start, finish); + + result.Items.Count.Should().Be(2); + result.Items[0].Start.TimeOfDay.Should().Be(TimeSpan.Zero); + result.Items[1].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(6)); + result.Items[1].Finish.TimeOfDay.Should().Be(TimeSpan.FromHours(12)); + } + + [Test] + public async Task ChronologicalContent_Should_CreateChronologicalItems() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), + TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) + }; + + (PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Chronological); + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(4); + + Playout result = await builder.BuildPlayoutItems(playout, start, finish); + + result.Items.Count.Should().Be(4); + result.Items[0].Start.TimeOfDay.Should().Be(TimeSpan.Zero); + result.Items[0].MediaItemId.Should().Be(1); + result.Items[1].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(1)); + result.Items[1].MediaItemId.Should().Be(2); + result.Items[2].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(2)); + result.Items[2].MediaItemId.Should().Be(1); + result.Items[3].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(3)); + result.Items[3].MediaItemId.Should().Be(2); + } + + [Test] + public async Task ChronologicalFlood_Should_AnchorAndMaintainExistingPlayout() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(6), DateTime.Today), + TestMovie(2, TimeSpan.FromHours(6), DateTime.Today.AddHours(1)) + }; + + (PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Chronological); + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.BuildPlayoutItems(playout, start, finish); + + result.Items.Count.Should().Be(1); + result.Items.Head().MediaItemId.Should().Be(1); + + result.Anchor.NextStart.Should().Be(DateTime.Today.AddHours(6)); + + result.ProgramScheduleAnchors.Count.Should().Be(1); + result.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(1); + + DateTimeOffset start2 = HoursAfterMidnight(1); + DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6); + + Playout result2 = await builder.BuildPlayoutItems(playout, start2, finish2); + + result2.Items.Count.Should().Be(2); + result2.Items.Last().Start.TimeOfDay.Should().Be(TimeSpan.FromHours(6)); + result2.Items.Last().MediaItemId.Should().Be(2); + + result2.Anchor.NextStart.Should().Be(DateTime.Today.AddHours(12)); + result2.ProgramScheduleAnchors.Count.Should().Be(1); + result2.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(0); + } + + [Test] + public async Task ChronologicalFlood_Should_AnchorAndReturnNewPlayoutItems() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(6), DateTime.Today), + TestMovie(2, TimeSpan.FromHours(6), DateTime.Today.AddHours(1)) + }; + + (PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Chronological); + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.BuildPlayoutItems(playout, start, finish); + + result.Items.Count.Should().Be(1); + result.Items.Head().MediaItemId.Should().Be(1); + + result.Anchor.NextStart.Should().Be(DateTime.Today.AddHours(6)); + result.ProgramScheduleAnchors.Count.Should().Be(1); + result.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(1); + + DateTimeOffset start2 = HoursAfterMidnight(1); + DateTimeOffset finish2 = start2 + TimeSpan.FromHours(12); + + Playout result2 = await builder.BuildPlayoutItems(playout, start2, finish2); + + result2.Items.Count.Should().Be(3); + result2.Items[1].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(6)); + result2.Items[1].MediaItemId.Should().Be(2); + result2.Items[2].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(12)); + result2.Items[2].MediaItemId.Should().Be(1); + + result2.Anchor.NextStart.Should().Be(DateTime.Today.AddHours(18)); + result2.ProgramScheduleAnchors.Count.Should().Be(1); + result2.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(1); + } + + [Test] + public async Task ShuffleFloodRebuild_Should_IgnoreAnchors() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(1), DateTime.Today), + TestMovie(2, TimeSpan.FromHours(1), DateTime.Today.AddHours(1)), + TestMovie(3, TimeSpan.FromHours(1), DateTime.Today.AddHours(2)), + TestMovie(4, TimeSpan.FromHours(1), DateTime.Today.AddHours(3)), + TestMovie(5, TimeSpan.FromHours(1), DateTime.Today.AddHours(4)), + TestMovie(6, TimeSpan.FromHours(1), DateTime.Today.AddHours(5)) + }; + + (PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Shuffle); + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.BuildPlayoutItems(playout, start, finish); + + result.Items.Count.Should().Be(6); + result.Anchor.NextStart.Should().Be(DateTime.Today.AddHours(6)); + + result.ProgramScheduleAnchors.Count.Should().Be(1); + result.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(0); + + int firstSeedValue = result.ProgramScheduleAnchors.Head().EnumeratorState.Seed; + + DateTimeOffset start2 = HoursAfterMidnight(0); + DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6); + + Playout result2 = await builder.BuildPlayoutItems(playout, start2, finish2, true); + + result2.Items.Count.Should().Be(6); + result2.Anchor.NextStart.Should().Be(DateTime.Today.AddHours(6)); + + result2.ProgramScheduleAnchors.Count.Should().Be(1); + result2.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(0); + + int secondSeedValue = result2.ProgramScheduleAnchors.Head().EnumeratorState.Seed; + + firstSeedValue.Should().NotBe(secondSeedValue); + } + + [Test] + public async Task ShuffleFlood_Should_MaintainRandomSeed() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(1), DateTime.Today), + TestMovie(2, TimeSpan.FromHours(1), DateTime.Today.AddHours(1)), + TestMovie(3, TimeSpan.FromHours(1), DateTime.Today.AddHours(3)) + }; + + (PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Shuffle); + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.BuildPlayoutItems(playout, start, finish); + + result.Items.Count.Should().Be(6); + result.ProgramScheduleAnchors.Count.Should().Be(1); + result.ProgramScheduleAnchors.Head().EnumeratorState.Seed.Should().BeGreaterThan(0); + + int firstSeedValue = result.ProgramScheduleAnchors.Head().EnumeratorState.Seed; + + DateTimeOffset start2 = HoursAfterMidnight(0); + DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6); + + Playout result2 = await builder.BuildPlayoutItems(playout, start2, finish2); + + int secondSeedValue = result2.ProgramScheduleAnchors.Head().EnumeratorState.Seed; + + firstSeedValue.Should().Be(secondSeedValue); + } + + [Test] + public async Task FloodContent_Should_FloodAroundFixedContent_One() + { + var floodCollection = new SimpleMediaCollection + { + Id = 1, + Name = "Flood Items", + Items = new List + { + TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), + TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) + } + }; + + var fixedCollection = new SimpleMediaCollection + { + Id = 2, + Name = "Fixed Items", + Items = new List + { + TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)) + } + }; + + var fakeRepository = new FakeMediaCollectionRepository( + Map( + (floodCollection.Id, floodCollection.Items.ToList()), + (fixedCollection.Id, fixedCollection.Items.ToList()))); + + var items = new List + { + new ProgramScheduleItemFlood + { + Index = 1, + MediaCollection = floodCollection, + MediaCollectionId = floodCollection.Id, + StartTime = null + }, + new ProgramScheduleItemOne + { + Index = 2, + MediaCollection = fixedCollection, + MediaCollectionId = fixedCollection.Id, + StartTime = TimeSpan.FromHours(3) + } + }; + + var playout = new Playout + { + ProgramSchedule = new ProgramSchedule + { + Items = items, + MediaCollectionPlaybackOrder = PlaybackOrder.Chronological + }, + Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } + }; + + var builder = new PlayoutBuilder(fakeRepository, _logger); + + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.BuildPlayoutItems(playout, start, finish); + + result.Items.Count.Should().Be(5); + result.Items[0].Start.TimeOfDay.Should().Be(TimeSpan.Zero); + result.Items[0].MediaItemId.Should().Be(1); + result.Items[1].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(1)); + result.Items[1].MediaItemId.Should().Be(2); + result.Items[2].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(2)); + result.Items[2].MediaItemId.Should().Be(1); + result.Items[3].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(3)); + result.Items[3].MediaItemId.Should().Be(3); + result.Items[4].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(5)); + result.Items[4].MediaItemId.Should().Be(2); + } + + [Test] + public async Task FloodContent_Should_FloodAroundFixedContent_Multiple() + { + var floodCollection = new SimpleMediaCollection + { + Id = 1, + Name = "Flood Items", + Items = new List + { + TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), + TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) + } + }; + + var fixedCollection = new SimpleMediaCollection + { + Id = 2, + Name = "Fixed Items", + Items = new List + { + TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)), + TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 2)) + } + }; + + var fakeRepository = new FakeMediaCollectionRepository( + Map( + (floodCollection.Id, floodCollection.Items.ToList()), + (fixedCollection.Id, fixedCollection.Items.ToList()))); + + var items = new List + { + new ProgramScheduleItemFlood + { + Index = 1, + MediaCollection = floodCollection, + MediaCollectionId = floodCollection.Id, + StartTime = null + }, + new ProgramScheduleItemMultiple + { + Index = 2, + MediaCollection = fixedCollection, + MediaCollectionId = fixedCollection.Id, + StartTime = TimeSpan.FromHours(3), + Count = 2 + } + }; + + var playout = new Playout + { + ProgramSchedule = new ProgramSchedule + { + Items = items, + MediaCollectionPlaybackOrder = PlaybackOrder.Chronological + }, + Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } + }; + + var builder = new PlayoutBuilder(fakeRepository, _logger); + + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(7); + + Playout result = await builder.BuildPlayoutItems(playout, start, finish); + + result.Items.Count.Should().Be(6); + + result.Items[0].Start.TimeOfDay.Should().Be(TimeSpan.Zero); + result.Items[0].MediaItemId.Should().Be(1); + result.Items[1].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(1)); + result.Items[1].MediaItemId.Should().Be(2); + result.Items[2].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(2)); + result.Items[2].MediaItemId.Should().Be(1); + + result.Items[3].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(3)); + result.Items[3].MediaItemId.Should().Be(3); + result.Items[4].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(5)); + result.Items[4].MediaItemId.Should().Be(4); + + result.Items[5].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(6)); + result.Items[5].MediaItemId.Should().Be(2); + } + + [Test] + public async Task FloodContent_Should_FloodAroundFixedContent_DurationWithOfflineTail() + { + var floodCollection = new SimpleMediaCollection + { + Id = 1, + Name = "Flood Items", + Items = new List + { + TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), + TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) + } + }; + + var fixedCollection = new SimpleMediaCollection + { + Id = 2, + Name = "Fixed Items", + Items = new List + { + TestMovie(3, TimeSpan.FromHours(0.75), new DateTime(2020, 1, 1)), + TestMovie(4, TimeSpan.FromHours(0.75), new DateTime(2020, 1, 2)) + } + }; + + var fakeRepository = new FakeMediaCollectionRepository( + Map( + (floodCollection.Id, floodCollection.Items.ToList()), + (fixedCollection.Id, fixedCollection.Items.ToList()))); + + var items = new List + { + new ProgramScheduleItemFlood + { + Index = 1, + MediaCollection = floodCollection, + MediaCollectionId = floodCollection.Id, + StartTime = null + }, + new ProgramScheduleItemDuration + { + Index = 2, + MediaCollection = fixedCollection, + MediaCollectionId = fixedCollection.Id, + StartTime = TimeSpan.FromHours(2), + PlayoutDuration = TimeSpan.FromHours(2), + OfflineTail = true // last 30 minutes will be offline + } + }; + + var playout = new Playout + { + ProgramSchedule = new ProgramSchedule + { + Items = items, + MediaCollectionPlaybackOrder = PlaybackOrder.Chronological + }, + Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } + }; + + var builder = new PlayoutBuilder(fakeRepository, _logger); + + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.BuildPlayoutItems(playout, start, finish); + + result.Items.Count.Should().Be(6); + + result.Items[0].Start.TimeOfDay.Should().Be(TimeSpan.Zero); + result.Items[0].MediaItemId.Should().Be(1); + result.Items[1].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(1)); + result.Items[1].MediaItemId.Should().Be(2); + + result.Items[2].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(2)); + result.Items[2].MediaItemId.Should().Be(3); + result.Items[3].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(2.75)); + result.Items[3].MediaItemId.Should().Be(4); + + result.Items[4].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(4)); + result.Items[4].MediaItemId.Should().Be(1); + result.Items[5].Start.TimeOfDay.Should().Be(TimeSpan.FromHours(5)); + result.Items[5].MediaItemId.Should().Be(2); + } + + private static DateTimeOffset HoursAfterMidnight(int hours) + { + DateTimeOffset now = DateTimeOffset.Now; + return now - now.TimeOfDay + TimeSpan.FromHours(hours); + } + + private static ProgramScheduleItem Flood(MediaCollection mediaCollection) => + new ProgramScheduleItemFlood + { + Index = 1, + MediaCollection = mediaCollection, + MediaCollectionId = mediaCollection.Id, + StartTime = null + }; + + private static MediaItem TestMovie(int id, TimeSpan duration, DateTime aired) => + new() + { + Id = id, + Metadata = new MediaMetadata { Duration = duration, MediaType = MediaType.Movie, Aired = aired } + }; + + private TestData TestDataFloodForItems(List mediaItems, PlaybackOrder playbackOrder) + { + var mediaCollection = new SimpleMediaCollection + { + Id = 1, + Items = mediaItems + }; + + var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems))); + var builder = new PlayoutBuilder(collectionRepo, _logger); + + var items = new List { Flood(mediaCollection) }; + + var playout = new Playout + { + Id = 1, + ProgramSchedule = new ProgramSchedule { Items = items, MediaCollectionPlaybackOrder = playbackOrder }, + Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } + }; + + return new TestData(builder, playout); + } + + private record TestData(PlayoutBuilder Builder, Playout Playout); + } +} diff --git a/ErsatzTV.Core.Tests/Scheduling/RandomizedContentTests.cs b/ErsatzTV.Core.Tests/Scheduling/RandomizedContentTests.cs new file mode 100644 index 000000000..7c12b12a0 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/RandomizedContentTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using FluentAssertions; +using NUnit.Framework; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Tests.Scheduling +{ + public class RandomizedContentTests + { + private const int KnownSeed = 22295; + + private readonly List _expected = new() + { + 5, 7, 7, 8, 6, 7, 8, 9, 10, 7, 5, 1, 7, 2, 5, 6, 1, 4, 5, 6, 4, 5, 1, 6, 5, 7, 1, 3, 9, 9, 9, 3, + 3, 2, 3, 4, 5, 6, 9, 3, 6, 9, 7, 1, 2, 10, 3, 8, 3, 8, 8, 3, 1, 5, 4, 3, 6, 4, 6, 2, 9, 8, 3, 1, 8, 5, + 1, 8, 2, 1, 1, 5, 5, 5, 3, 5, 8, 10, 4, 8, 7, 3, 3, 4, 4, 9, 2, 8, 8, 10, 8, 4, 3, 10, 7, 8, 9, 9 + }; + + [Test] + public void Episodes_Should_Randomize() + { + List contents = Episodes(10); + + var state = new MediaCollectionEnumeratorState(); + + var randomizedContent = new RandomizedMediaCollectionEnumerator(contents, state); + + var list = new List(); + for (var i = 1; i <= 10; i++) + { + randomizedContent.Current.IsSome.Should().BeTrue(); + randomizedContent.Current.Do(c => list.Add(c.Id)); + + randomizedContent.MoveNext(); + } + + list.Should().NotEqual(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }); + list.Should().NotEqual(new[] { 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 }); + } + + [Test] + public void State_Index_Should_Increment() + { + List contents = Episodes(10); + var state = new MediaCollectionEnumeratorState(); + + var randomizedContent = new RandomizedMediaCollectionEnumerator(contents, state); + + for (var i = 1; i <= 10; i++) + { + randomizedContent.State.Index.Should().Be(i); + + randomizedContent.MoveNext(); + } + } + + [Test] + public void State_Should_Impact_Iterator_Start() + { + List contents = Episodes(10); + var state = new MediaCollectionEnumeratorState { Index = 5, Seed = KnownSeed }; + + var randomizedContent = new RandomizedMediaCollectionEnumerator(contents, state); + + for (var i = 6; i <= 99; i++) + { + randomizedContent.Current.IsSome.Should().BeTrue(); + // this test data setup/expectation is confusing + randomizedContent.Current.Map(c => c.Id).IfNone(-1).Should().Be(_expected[i - 2]); + randomizedContent.State.Index.Should().Be(i); + + randomizedContent.MoveNext(); + } + } + + [Test] + public void Peek_Should_Not_Impact_Current_Or_Wrapping() + { + List contents = Episodes(10); + var state = new MediaCollectionEnumeratorState { Seed = KnownSeed }; + + var randomizedContent = new RandomizedMediaCollectionEnumerator(contents, state); + + for (var i = 0; i < 97; i++) + { + randomizedContent.MoveNext(); + randomizedContent.Current.IsSome.Should().BeTrue(); + randomizedContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(_expected[i]); + randomizedContent.Peek.Map(x => x.Id).IfNone(-1).Should().Be(_expected[i + 1]); + randomizedContent.Peek.Map(x => x.Id).IfNone(-1).Should().Be(_expected[i + 1]); + } + } + + private static List Episodes(int count) => + Range(1, count).Map( + i => new MediaItem + { + Id = i, + Metadata = new MediaMetadata + { + MediaType = MediaType.TvShow, Aired = new DateTime(2020, 1, i) + } + }) + .Reverse() + .ToList(); + } +} diff --git a/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs b/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs new file mode 100644 index 000000000..0b665982c --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using FluentAssertions; +using NUnit.Framework; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Tests.Scheduling +{ + public class ShuffledContentTests + { + // this seed will produce (shuffle) 1-10 in order + private const int MagicSeed = 670596; + + [Test] + public void Episodes_Should_Shuffle() + { + List contents = Episodes(10); + + var state = new MediaCollectionEnumeratorState(); + + var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state); + + var list = new List(); + for (var i = 1; i <= 10; i++) + { + shuffledContent.Current.IsSome.Should().BeTrue(); + shuffledContent.Current.Do(x => list.Add(x.Id)); + shuffledContent.MoveNext(); + } + + list.Should().NotEqual(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }); + list.Should().BeEquivalentTo(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + } + + [Test] + public void State_Index_Should_Increment() + { + List contents = Episodes(10); + var state = new MediaCollectionEnumeratorState(); + + var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state); + + for (var i = 0; i < 10; i++) + { + shuffledContent.State.Index.Should().Be(i); + shuffledContent.MoveNext(); + } + } + + [Test] + public void State_Should_Impact_Iterator_Start() + { + List contents = Episodes(10); + var state = new MediaCollectionEnumeratorState { Index = 5, Seed = MagicSeed }; + + var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state); + + for (var i = 6; i <= 10; i++) + { + shuffledContent.Current.IsSome.Should().BeTrue(); + shuffledContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i); + shuffledContent.State.Index.Should().Be(i - 1); + shuffledContent.MoveNext(); + } + } + + [Test] + public void Peek_Should_Not_Impact_Current_Or_Wrapping() + { + List contents = Episodes(10); + var state = new MediaCollectionEnumeratorState { Seed = MagicSeed }; + var expected = new List + { + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 6, 1, 4, 2, 10, 7, 3, 5, 8, 9, 10, 9, 4, 5, 1, 7, 3, 2, 8, 6, 3, 5, 4, 2, + 10, 8, 7, 1, 6, 9, 3, 4, 7, 10, 6, 9, 1, 2, 8, 5, 8, 1, 3, 6, 5, 7, 9, 4, 2, 10, 6, 1, 4, 3, 5, 10, 2, + 7, 8, 9, 6, 10, 4, 3, 8, 1, 5, 9, 2, 7, 8, 6, 4, 1, 9, 7, 3, 10, 5, 2, 5, 9, 2, 6, 7, 10, 3, 4, 1, 8 + }; + + var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state); + + for (var i = 0; i < 99; i++) + { + shuffledContent.Current.IsSome.Should().BeTrue(); + shuffledContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(expected[i]); + shuffledContent.Peek.Map(x => x.Id).IfNone(-1).Should().Be(expected[i + 1]); + shuffledContent.Peek.Map(x => x.Id).IfNone(-1).Should().Be(expected[i + 1]); + + shuffledContent.MoveNext(); + } + } + + private static List Episodes(int count) => + Range(1, count).Map( + i => new MediaItem + { + Id = i, + Metadata = new MediaMetadata + { + MediaType = MediaType.TvShow, Aired = new DateTime(2020, 1, i) + } + }) + .Reverse() + .ToList(); + } +} diff --git a/ErsatzTV.Core/AggregateModels/GenericIntegerId.cs b/ErsatzTV.Core/AggregateModels/GenericIntegerId.cs new file mode 100644 index 000000000..fec0b0164 --- /dev/null +++ b/ErsatzTV.Core/AggregateModels/GenericIntegerId.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Core.AggregateModels +{ + public record GenericIntegerId(int Id); +} diff --git a/ErsatzTV.Core/AggregateModels/MediaCollectionSummary.cs b/ErsatzTV.Core/AggregateModels/MediaCollectionSummary.cs new file mode 100644 index 000000000..0df552482 --- /dev/null +++ b/ErsatzTV.Core/AggregateModels/MediaCollectionSummary.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Core.AggregateModels +{ + public record MediaCollectionSummary(int Id, string Name, int ItemCount, bool IsSimple); +} diff --git a/ErsatzTV.Core/BaseError.cs b/ErsatzTV.Core/BaseError.cs new file mode 100644 index 000000000..b3d3c105c --- /dev/null +++ b/ErsatzTV.Core/BaseError.cs @@ -0,0 +1,18 @@ +using LanguageExt; + +namespace ErsatzTV.Core +{ + public class BaseError : NewType + { + public BaseError(string value) : base(value) + { + } + + public static implicit operator BaseError(string str) => New(str); + } + + public static class ErrorExtensions + { + public static BaseError Join(this Seq errors) => string.Join("; ", errors); + } +} diff --git a/ErsatzTV.Core/Domain/Channel.cs b/ErsatzTV.Core/Domain/Channel.cs new file mode 100644 index 000000000..c12b8fad5 --- /dev/null +++ b/ErsatzTV.Core/Domain/Channel.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace ErsatzTV.Core.Domain +{ + public class Channel + { + public Channel(Guid uniqueId) => UniqueId = uniqueId; + + public int Id { get; set; } + public Guid UniqueId { get; init; } + public int Number { get; set; } + public string Name { get; set; } + public string Logo { get; set; } + public int FFmpegProfileId { get; set; } + public FFmpegProfile FFmpegProfile { get; set; } + public StreamingMode StreamingMode { get; set; } + + public List Playouts { get; set; } + // public SourceMode Mode { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/ConfigElement.cs b/ErsatzTV.Core/Domain/ConfigElement.cs new file mode 100644 index 000000000..b410b55c0 --- /dev/null +++ b/ErsatzTV.Core/Domain/ConfigElement.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Domain +{ + public class ConfigElement + { + public int Id { get; set; } + public string Key { get; set; } + public string Value { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/ConfigElementKey.cs b/ErsatzTV.Core/Domain/ConfigElementKey.cs new file mode 100644 index 000000000..286bbefe1 --- /dev/null +++ b/ErsatzTV.Core/Domain/ConfigElementKey.cs @@ -0,0 +1,14 @@ +namespace ErsatzTV.Core.Domain +{ + public class ConfigElementKey + { + private ConfigElementKey(string key) => Key = key; + + public string Key { get; } + + public static ConfigElementKey FFmpegPath => new("ffmpeg.ffmpeg_path"); + public static ConfigElementKey FFprobePath => new("ffmpeg.ffprobe_path"); + public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id"); + public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id"); + } +} diff --git a/ErsatzTV.Core/Domain/FFmpegProfile.cs b/ErsatzTV.Core/Domain/FFmpegProfile.cs new file mode 100644 index 000000000..c12aa460c --- /dev/null +++ b/ErsatzTV.Core/Domain/FFmpegProfile.cs @@ -0,0 +1,48 @@ +namespace ErsatzTV.Core.Domain +{ + public record FFmpegProfile + { + public int Id { get; set; } + public string Name { get; set; } + public int ThreadCount { get; set; } + public bool Transcode { get; set; } + public int ResolutionId { get; set; } + public Resolution Resolution { get; set; } + public bool NormalizeResolution { get; set; } + public string VideoCodec { get; set; } + public bool NormalizeVideoCodec { get; set; } + public int VideoBitrate { get; set; } + public int VideoBufferSize { get; set; } + public string AudioCodec { get; set; } + public bool NormalizeAudioCodec { get; set; } + public int AudioBitrate { get; set; } + public int AudioBufferSize { get; set; } + public int AudioVolume { get; set; } + public int AudioChannels { get; set; } + public int AudioSampleRate { get; set; } + public bool NormalizeAudio { get; set; } + + public static FFmpegProfile New(string name, Resolution resolution) => + new() + { + Name = name, + ThreadCount = 4, + Transcode = true, + ResolutionId = resolution.Id, + Resolution = resolution, + VideoCodec = "libx264", + AudioCodec = "ac3", + VideoBitrate = 2000, + VideoBufferSize = 2000, + AudioBitrate = 192, + AudioBufferSize = 50, + AudioVolume = 100, + AudioChannels = 2, + AudioSampleRate = 48, + NormalizeResolution = true, + NormalizeVideoCodec = true, + NormalizeAudioCodec = true, + NormalizeAudio = true + }; + } +} diff --git a/ErsatzTV.Core/Domain/LocalMediaSource.cs b/ErsatzTV.Core/Domain/LocalMediaSource.cs new file mode 100644 index 000000000..23cfcdc9f --- /dev/null +++ b/ErsatzTV.Core/Domain/LocalMediaSource.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.Core.Domain +{ + public class LocalMediaSource : MediaSource + { + public LocalMediaSource() => SourceType = MediaSourceType.Local; + + public MediaType MediaType { get; set; } + public string Folder { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaCollection.cs b/ErsatzTV.Core/Domain/MediaCollection.cs new file mode 100644 index 000000000..67d13016a --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaCollection.cs @@ -0,0 +1,10 @@ +using LanguageExt; + +namespace ErsatzTV.Core.Domain +{ + public abstract class MediaCollection : Record + { + public int Id { get; set; } + public string Name { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaCollectionEnumeratorState.cs b/ErsatzTV.Core/Domain/MediaCollectionEnumeratorState.cs new file mode 100644 index 000000000..9c0013f94 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaCollectionEnumeratorState.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain +{ + public class MediaCollectionEnumeratorState + { + public int Seed { get; set; } + public int Index { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaItem.cs b/ErsatzTV.Core/Domain/MediaItem.cs new file mode 100644 index 000000000..39adcc026 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaItem.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace ErsatzTV.Core.Domain +{ + public class MediaItem + { + public int Id { get; set; } + public int MediaSourceId { get; set; } + public MediaSource Source { get; set; } + public string Path { get; set; } + public MediaMetadata Metadata { get; set; } + public DateTime? LastWriteTime { get; set; } + public IList SimpleMediaCollections { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaMetadata.cs b/ErsatzTV.Core/Domain/MediaMetadata.cs new file mode 100644 index 000000000..30d93bf1a --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaMetadata.cs @@ -0,0 +1,25 @@ +using System; +using ErsatzTV.Core.Interfaces.FFmpeg; + +namespace ErsatzTV.Core.Domain +{ + public record MediaMetadata : IDisplaySize + { + public TimeSpan Duration { get; set; } + public string SampleAspectRatio { get; set; } + public string DisplayAspectRatio { get; set; } + public string VideoCodec { get; set; } + public string AudioCodec { get; set; } + public MediaType MediaType { get; set; } + public string Title { get; set; } + public string Subtitle { get; set; } + public string Description { get; set; } + public int? SeasonNumber { get; set; } + public int? EpisodeNumber { get; set; } + public string ContentRating { get; set; } + public DateTime? Aired { get; set; } + public VideoScanType VideoScanType { get; set; } + public int Width { get; set; } + public int Height { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaSource.cs b/ErsatzTV.Core/Domain/MediaSource.cs new file mode 100644 index 000000000..c8c604240 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaSource.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Domain +{ + public abstract class MediaSource + { + public int Id { get; set; } + public MediaSourceType SourceType { get; set; } + public string Name { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaSourceType.cs b/ErsatzTV.Core/Domain/MediaSourceType.cs new file mode 100644 index 000000000..05406efa5 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaSourceType.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.Core.Domain +{ + public enum MediaSourceType + { + None = 0, + Local = 1, + Plex = 2, + Jellyfin = 3 + } +} diff --git a/ErsatzTV.Core/Domain/MediaType.cs b/ErsatzTV.Core/Domain/MediaType.cs new file mode 100644 index 000000000..49d208210 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaType.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Domain +{ + public enum MediaType + { + Other = 0, + TvShow = 1, + Movie = 2 + } +} diff --git a/ErsatzTV.Core/Domain/PlaybackOrder.cs b/ErsatzTV.Core/Domain/PlaybackOrder.cs new file mode 100644 index 000000000..34f1ed99d --- /dev/null +++ b/ErsatzTV.Core/Domain/PlaybackOrder.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Domain +{ + public enum PlaybackOrder + { + Chronological = 1, + Random = 2, + Shuffle = 3 + } +} diff --git a/ErsatzTV.Core/Domain/Playout.cs b/ErsatzTV.Core/Domain/Playout.cs new file mode 100644 index 000000000..e5abb2a5b --- /dev/null +++ b/ErsatzTV.Core/Domain/Playout.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Core.Domain +{ + public class Playout + { + public int Id { get; set; } + public int ChannelId { get; set; } + public Channel Channel { get; set; } + public int ProgramScheduleId { get; set; } + public ProgramSchedule ProgramSchedule { get; set; } + public ProgramSchedulePlayoutType ProgramSchedulePlayoutType { get; set; } + public List Items { get; set; } + public PlayoutAnchor Anchor { get; set; } + public List ProgramScheduleAnchors { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/PlayoutAnchor.cs b/ErsatzTV.Core/Domain/PlayoutAnchor.cs new file mode 100644 index 000000000..6b99ba12d --- /dev/null +++ b/ErsatzTV.Core/Domain/PlayoutAnchor.cs @@ -0,0 +1,13 @@ +using System; + +namespace ErsatzTV.Core.Domain +{ + public class PlayoutAnchor + { + public int NextScheduleItemId { get; set; } + + public ProgramScheduleItem NextScheduleItem { get; set; } + + public DateTimeOffset NextStart { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/PlayoutItem.cs b/ErsatzTV.Core/Domain/PlayoutItem.cs new file mode 100644 index 000000000..805db3c51 --- /dev/null +++ b/ErsatzTV.Core/Domain/PlayoutItem.cs @@ -0,0 +1,15 @@ +using System; + +namespace ErsatzTV.Core.Domain +{ + public class PlayoutItem + { + public int Id { get; set; } + public int MediaItemId { get; set; } + public MediaItem MediaItem { get; set; } + public DateTimeOffset Start { get; set; } + public DateTimeOffset Finish { get; set; } + public int PlayoutId { get; set; } + public Playout Playout { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/PlayoutMode.cs b/ErsatzTV.Core/Domain/PlayoutMode.cs new file mode 100644 index 000000000..b2440063e --- /dev/null +++ b/ErsatzTV.Core/Domain/PlayoutMode.cs @@ -0,0 +1,25 @@ +namespace ErsatzTV.Core.Domain +{ + public enum PlayoutMode + { + /// + /// Play items one after the other until a fixed start item is encountered + /// + Flood = 1, + + /// + /// Play one item from the collection + /// + One = 2, + + /// + /// Play a variable number of items from the collection + /// + Multiple = 3, + + /// + /// Play however many items will fit in the specified duration + /// + Duration = 4 + } +} diff --git a/ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs b/ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs new file mode 100644 index 000000000..174055613 --- /dev/null +++ b/ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs @@ -0,0 +1,13 @@ +namespace ErsatzTV.Core.Domain +{ + public class PlayoutProgramScheduleAnchor + { + public int PlayoutId { get; set; } + public Playout Playout { get; set; } + public int ProgramScheduleId { get; set; } + public ProgramSchedule ProgramSchedule { get; set; } + public int MediaCollectionId { get; set; } + public MediaCollection MediaCollection { get; set; } + public MediaCollectionEnumeratorState EnumeratorState { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/PlexMediaSource.cs b/ErsatzTV.Core/Domain/PlexMediaSource.cs new file mode 100644 index 000000000..ef882cce0 --- /dev/null +++ b/ErsatzTV.Core/Domain/PlexMediaSource.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Core.Domain +{ + public class PlexMediaSource : MediaSource + { + public PlexMediaSource() => SourceType = MediaSourceType.Plex; + public string ProductVersion { get; set; } + + public string ClientIdentifier { get; set; } + + // public bool IsOwned { get; set; } + public List Connections { get; set; } + public List Libraries { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/PlexMediaSourceConnection.cs b/ErsatzTV.Core/Domain/PlexMediaSourceConnection.cs new file mode 100644 index 000000000..9083a5b45 --- /dev/null +++ b/ErsatzTV.Core/Domain/PlexMediaSourceConnection.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Domain +{ + public class PlexMediaSourceConnection + { + public int Id { get; set; } + public bool IsActive { get; set; } + public string Uri { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/PlexMediaSourceLibrary.cs b/ErsatzTV.Core/Domain/PlexMediaSourceLibrary.cs new file mode 100644 index 000000000..204ffe0e6 --- /dev/null +++ b/ErsatzTV.Core/Domain/PlexMediaSourceLibrary.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.Core.Domain +{ + public class PlexMediaSourceLibrary + { + public int Id { get; set; } + public string Key { get; init; } + public string Name { get; init; } + public MediaType MediaType { get; init; } + } +} diff --git a/ErsatzTV.Core/Domain/ProgramSchedule.cs b/ErsatzTV.Core/Domain/ProgramSchedule.cs new file mode 100644 index 000000000..6ed723f33 --- /dev/null +++ b/ErsatzTV.Core/Domain/ProgramSchedule.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Core.Domain +{ + public class ProgramSchedule + { + public int Id { get; set; } + public string Name { get; set; } + public PlaybackOrder MediaCollectionPlaybackOrder { get; set; } + public List Items { get; set; } + public List Playouts { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/ProgramScheduleItem.cs b/ErsatzTV.Core/Domain/ProgramScheduleItem.cs new file mode 100644 index 000000000..25363406b --- /dev/null +++ b/ErsatzTV.Core/Domain/ProgramScheduleItem.cs @@ -0,0 +1,16 @@ +using System; + +namespace ErsatzTV.Core.Domain +{ + public abstract class ProgramScheduleItem + { + public int Id { get; set; } + public int Index { get; set; } + public StartType StartType => StartTime.HasValue ? StartType.Fixed : StartType.Dynamic; + public TimeSpan? StartTime { get; set; } + public int MediaCollectionId { get; set; } + public MediaCollection MediaCollection { get; set; } + public int ProgramScheduleId { get; set; } + public ProgramSchedule ProgramSchedule { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/ProgramScheduleItemDuration.cs b/ErsatzTV.Core/Domain/ProgramScheduleItemDuration.cs new file mode 100644 index 000000000..960b34a79 --- /dev/null +++ b/ErsatzTV.Core/Domain/ProgramScheduleItemDuration.cs @@ -0,0 +1,10 @@ +using System; + +namespace ErsatzTV.Core.Domain +{ + public class ProgramScheduleItemDuration : ProgramScheduleItem + { + public TimeSpan PlayoutDuration { get; set; } + public bool OfflineTail { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/ProgramScheduleItemFlood.cs b/ErsatzTV.Core/Domain/ProgramScheduleItemFlood.cs new file mode 100644 index 000000000..dbad4312d --- /dev/null +++ b/ErsatzTV.Core/Domain/ProgramScheduleItemFlood.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Domain +{ + public class ProgramScheduleItemFlood : ProgramScheduleItem + { + } +} diff --git a/ErsatzTV.Core/Domain/ProgramScheduleItemMultiple.cs b/ErsatzTV.Core/Domain/ProgramScheduleItemMultiple.cs new file mode 100644 index 000000000..9f86437fd --- /dev/null +++ b/ErsatzTV.Core/Domain/ProgramScheduleItemMultiple.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Core.Domain +{ + public class ProgramScheduleItemMultiple : ProgramScheduleItem + { + public int Count { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/ProgramScheduleItemOne.cs b/ErsatzTV.Core/Domain/ProgramScheduleItemOne.cs new file mode 100644 index 000000000..8fd15250a --- /dev/null +++ b/ErsatzTV.Core/Domain/ProgramScheduleItemOne.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Domain +{ + public class ProgramScheduleItemOne : ProgramScheduleItem + { + } +} diff --git a/ErsatzTV.Core/Domain/ProgramSchedulePlayoutType.cs b/ErsatzTV.Core/Domain/ProgramSchedulePlayoutType.cs new file mode 100644 index 000000000..a20695dfc --- /dev/null +++ b/ErsatzTV.Core/Domain/ProgramSchedulePlayoutType.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Domain +{ + public enum ProgramSchedulePlayoutType + { + None = 0, + Flood, + Daily + } +} diff --git a/ErsatzTV.Core/Domain/Resolution.cs b/ErsatzTV.Core/Domain/Resolution.cs new file mode 100644 index 000000000..7baea1333 --- /dev/null +++ b/ErsatzTV.Core/Domain/Resolution.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Interfaces.FFmpeg; + +namespace ErsatzTV.Core.Domain +{ + public class Resolution : IDisplaySize + { + public int Id { get; set; } + public string Name { get; set; } + public int Height { get; set; } + public int Width { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/ResolutionKey.cs b/ErsatzTV.Core/Domain/ResolutionKey.cs new file mode 100644 index 000000000..65d3d7377 --- /dev/null +++ b/ErsatzTV.Core/Domain/ResolutionKey.cs @@ -0,0 +1,11 @@ +namespace ErsatzTV.Core.Domain +{ + public enum ResolutionKey + { + None = 0, + W720H480 = 1, + W1280H720 = 2, + W1920H1080 = 3, + W3840H2160 = 4 + } +} diff --git a/ErsatzTV.Core/Domain/SimpleMediaCollection.cs b/ErsatzTV.Core/Domain/SimpleMediaCollection.cs new file mode 100644 index 000000000..6f7a1b134 --- /dev/null +++ b/ErsatzTV.Core/Domain/SimpleMediaCollection.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Core.Domain +{ + public class SimpleMediaCollection : MediaCollection + { + public IList Items { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/SourceMode.cs b/ErsatzTV.Core/Domain/SourceMode.cs new file mode 100644 index 000000000..b1930423d --- /dev/null +++ b/ErsatzTV.Core/Domain/SourceMode.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Domain +{ + public enum SourceMode + { + Transcode, + DirectPlay, + DirectPaths + } +} diff --git a/ErsatzTV.Core/Domain/StartType.cs b/ErsatzTV.Core/Domain/StartType.cs new file mode 100644 index 000000000..735e87e72 --- /dev/null +++ b/ErsatzTV.Core/Domain/StartType.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain +{ + public enum StartType + { + Dynamic = 1, + Fixed = 2 + } +} diff --git a/ErsatzTV.Core/Domain/StreamingMode.cs b/ErsatzTV.Core/Domain/StreamingMode.cs new file mode 100644 index 000000000..45220f3dd --- /dev/null +++ b/ErsatzTV.Core/Domain/StreamingMode.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain +{ + public enum StreamingMode + { + TransportStream = 1, + HttpLiveStreaming = 2 + } +} diff --git a/ErsatzTV.Core/Domain/TelevisionMediaCollection.cs b/ErsatzTV.Core/Domain/TelevisionMediaCollection.cs new file mode 100644 index 000000000..3d872c9b0 --- /dev/null +++ b/ErsatzTV.Core/Domain/TelevisionMediaCollection.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain +{ + public class TelevisionMediaCollection : MediaCollection + { + public string ShowTitle { get; set; } + public int? SeasonNumber { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/VideoScanType.cs b/ErsatzTV.Core/Domain/VideoScanType.cs new file mode 100644 index 000000000..443ce6f0a --- /dev/null +++ b/ErsatzTV.Core/Domain/VideoScanType.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Domain +{ + public enum VideoScanType + { + Unknown = 0, + Progressive = 1, + Interlaced = 2 + } +} diff --git a/ErsatzTV.Core/ErsatzTV.Core.csproj b/ErsatzTV.Core/ErsatzTV.Core.csproj new file mode 100644 index 000000000..b9863adf9 --- /dev/null +++ b/ErsatzTV.Core/ErsatzTV.Core.csproj @@ -0,0 +1,16 @@ + + + + net5.0 + + + + + + + + + + + + diff --git a/ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs b/ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs new file mode 100644 index 000000000..9067e2b59 --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.Core.FFmpeg +{ + public record ConcatPlaylist(string Scheme, string Host, int ChannelNumber) + { + public override string ToString() => + $@"ffconcat version 1.0 +file {Scheme}://{Host}/ffmpeg/stream/{ChannelNumber} +file {Scheme}://{Host}/ffmpeg/stream/{ChannelNumber}"; + } +} diff --git a/ErsatzTV.Core/FFmpeg/DisplaySize.cs b/ErsatzTV.Core/FFmpeg/DisplaySize.cs new file mode 100644 index 000000000..1613fea93 --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/DisplaySize.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core.Interfaces.FFmpeg; + +namespace ErsatzTV.Core.FFmpeg +{ + internal record DisplaySize(int Width, int Height) : IDisplaySize; +} diff --git a/ErsatzTV.Core/FFmpeg/DisplaySizeExtensions.cs b/ErsatzTV.Core/FFmpeg/DisplaySizeExtensions.cs new file mode 100644 index 000000000..2330909c9 --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/DisplaySizeExtensions.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Core.Interfaces.FFmpeg; + +namespace ErsatzTV.Core.FFmpeg +{ + public static class DisplaySizeExtensions + { + internal static IDisplaySize PadToEven(this IDisplaySize size) => + new DisplaySize(size.Width + size.Width % 2, size.Height + size.Height % 2); + + internal static bool IsSameSizeAs(this IDisplaySize @this, IDisplaySize that) => + @this.Width == that.Width && @this.Height == that.Height; + } +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLocator.cs b/ErsatzTV.Core/FFmpeg/FFmpegLocator.cs new file mode 100644 index 000000000..a459671d6 --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/FFmpegLocator.cs @@ -0,0 +1,74 @@ +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.FFmpeg +{ + public class FFmpegLocator : IFFmpegLocator + { + private readonly IConfigElementRepository _configElementRepository; + + public FFmpegLocator(IConfigElementRepository configElementRepository) => + _configElementRepository = configElementRepository; + + public async Task> ValidatePath(string executableBase, ConfigElementKey key) + { + Option setting = await _configElementRepository.Get(key); + + return await setting.MatchAsync( + async ce => + { + if (File.Exists(ce.Value)) + { + return ce.Value; + } + + // configured path was incorrect + await _configElementRepository.Delete(ce); + + return await LocateExecutableAsync(executableBase, key); + }, + async () => await LocateExecutableAsync(executableBase, key)); + } + + private async Task> LocateExecutableAsync(string executableBase, ConfigElementKey key) + { + Option maybePath = await LocateExecutableOnPathAsync(executableBase); + return await maybePath.MatchAsync( + async path => + { + await _configElementRepository.Add(new ConfigElement { Key = key.Key, Value = path }); + return Some(path); + }, + () => None); + } + + private async Task> LocateExecutableOnPathAsync(string executableBase) + { + string locateCommand = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "where" : "which"; + string executable = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? $"{executableBase}.exe" + : executableBase; + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + UseShellExecute = false, + FileName = locateCommand, + Arguments = executable, + RedirectStandardOutput = true + } + }; + p.Start(); + string path = (await p.StandardOutput.ReadToEndAsync()).Trim(); + await p.WaitForExitAsync(); + return p.ExitCode == 0 ? Some(path) : None; + } + } +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs new file mode 100644 index 000000000..fb2c434a4 --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using ErsatzTV.Core.Interfaces.FFmpeg; +using LanguageExt; + +namespace ErsatzTV.Core.FFmpeg +{ + public class FFmpegPlaybackSettings + { + public int ThreadCount { get; set; } + public List FormatFlags { get; set; } + public bool RealtimeOutput => true; + public Option StreamSeek { get; set; } + public Option ScaledSize { get; set; } + public bool PadToDesiredResolution { get; set; } + public string ScalingAlgorithm => "fast_bilinear"; // TODO: from config, add tests + public string VideoCodec { get; set; } + public Option VideoBitrate { get; set; } + public Option VideoBufferSize { get; set; } + public Option AudioBitrate { get; set; } + public Option AudioBufferSize { get; set; } + public Option AudioChannels { get; set; } + public Option AudioSampleRate { get; set; } + public Option AudioDuration { get; set; } + public string AudioCodec { get; set; } + public bool Deinterlace { get; set; } + } +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs new file mode 100644 index 000000000..f06e755d5 --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs @@ -0,0 +1,223 @@ +// zlib License +// +// Copyright (c) 2021 Dan Ferguson, Victor Hugo Soliz Kuncar, Jason Dove +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +using System; +using System.Collections.Generic; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.FFmpeg; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.FFmpeg +{ + public class FFmpegPlaybackSettingsCalculator + { + private static readonly List CommonFormatFlags = new() + { + "+genpts", + "+discardcorrupt", + "+igndts" + }; + + public FFmpegPlaybackSettings ConcatSettings => new() + { + ThreadCount = 1, + FormatFlags = CommonFormatFlags + }; + + public FFmpegPlaybackSettings CalculateSettings( + StreamingMode streamingMode, + FFmpegProfile ffmpegProfile, + PlayoutItem playoutItem, + DateTimeOffset now) + { + var result = new FFmpegPlaybackSettings + { + ThreadCount = ffmpegProfile.ThreadCount, + FormatFlags = CommonFormatFlags + }; + + if (now != playoutItem.Start) + { + result.StreamSeek = now - playoutItem.Start; + } + + switch (streamingMode) + { + case StreamingMode.HttpLiveStreaming: + result.AudioCodec = "copy"; + result.VideoCodec = "copy"; + result.Deinterlace = false; + break; + case StreamingMode.TransportStream: + if (NeedToScale(ffmpegProfile, playoutItem.MediaItem.Metadata)) + { + IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Metadata); + if (!scaledSize.IsSameSizeAs(playoutItem.MediaItem.Metadata)) + { + result.ScaledSize = Some( + CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Metadata)); + } + } + + IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(playoutItem.MediaItem.Metadata); + if (!sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution)) + { + result.PadToDesiredResolution = true; + } + + if (result.ScaledSize.IsSome || result.PadToDesiredResolution || + NeedToNormalizeVideoCodec(ffmpegProfile, playoutItem.MediaItem.Metadata)) + { + result.VideoCodec = ffmpegProfile.VideoCodec; + result.VideoBitrate = ffmpegProfile.VideoBitrate; + result.VideoBufferSize = ffmpegProfile.VideoBufferSize; + } + else + { + result.VideoCodec = "copy"; + } + + if (NeedToNormalizeAudioCodec(ffmpegProfile, playoutItem.MediaItem.Metadata)) + { + result.AudioCodec = ffmpegProfile.AudioCodec; + result.AudioBitrate = ffmpegProfile.AudioBitrate; + result.AudioBufferSize = ffmpegProfile.AudioBufferSize; + + if (ffmpegProfile.NormalizeAudio) + { + result.AudioChannels = ffmpegProfile.AudioChannels; + result.AudioSampleRate = ffmpegProfile.AudioSampleRate; + result.AudioDuration = playoutItem.MediaItem.Metadata.Duration; + } + } + else + { + result.AudioCodec = "copy"; + } + + if (playoutItem.MediaItem.Metadata.VideoScanType == VideoScanType.Interlaced) + { + result.Deinterlace = true; + } + + break; + } + + return result; + } + + public FFmpegPlaybackSettings CalculateErrorSettings(FFmpegProfile ffmpegProfile) => + new() + { + ThreadCount = ffmpegProfile.ThreadCount, + FormatFlags = CommonFormatFlags, + VideoCodec = ffmpegProfile.VideoCodec, + AudioCodec = ffmpegProfile.AudioCodec + }; + + private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) => + ffmpegProfile.NormalizeResolution && + IsIncorrectSize(ffmpegProfile.Resolution, mediaMetadata) || + IsTooLarge(ffmpegProfile.Resolution, mediaMetadata) || + IsOddSize(mediaMetadata); + + private static bool IsIncorrectSize(IDisplaySize desiredResolution, MediaMetadata mediaMetadata) => + IsAnamorphic(mediaMetadata) || + mediaMetadata.Width != desiredResolution.Width || + mediaMetadata.Height != desiredResolution.Height; + + private static bool IsTooLarge(IDisplaySize desiredResolution, IDisplaySize mediaSize) => + mediaSize.Height > desiredResolution.Height || + mediaSize.Width > desiredResolution.Width; + + private static bool IsOddSize(IDisplaySize displaySize) => + displaySize.Height % 2 == 1 || displaySize.Width % 2 == 1; + + private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) => + ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != mediaMetadata.VideoCodec; + + private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) => + ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != mediaMetadata.AudioCodec; + + private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) + { + IDisplaySize sarSize = SARSize(mediaMetadata); + int p = mediaMetadata.Width * sarSize.Width; + int q = mediaMetadata.Height * sarSize.Height; + int g = Gcd(q, p); + p = p / g; + q = q / g; + IDisplaySize targetSize = ffmpegProfile.Resolution; + int hw1 = targetSize.Width; + int hh1 = hw1 * q / p; + int hh2 = targetSize.Height; + int hw2 = targetSize.Height * p / q; + if (hh1 <= targetSize.Height) + { + return new DisplaySize(hw1, hh1); + } + + return new DisplaySize(hw2, hh2); + } + + private static int Gcd(int a, int b) + { + while (a != 0 && b != 0) + { + if (a > b) + { + a %= b; + } + else + { + b %= a; + } + } + + return a | b; + } + + private static bool IsAnamorphic(MediaMetadata mediaMetadata) + { + if (mediaMetadata.SampleAspectRatio == "1:1") + { + return false; + } + + if (mediaMetadata.SampleAspectRatio != "0:1") + { + return true; + } + + if (mediaMetadata.DisplayAspectRatio == "0:1") + { + return false; + } + + return mediaMetadata.DisplayAspectRatio != $"{mediaMetadata.Width}:{mediaMetadata.Height}"; + } + + private static IDisplaySize SARSize(MediaMetadata mediaMetadata) + { + string[] split = mediaMetadata.SampleAspectRatio.Split(":"); + return new DisplaySize(int.Parse(split[0]), int.Parse(split[1])); + } + } +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs new file mode 100644 index 000000000..d35647aa7 --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs @@ -0,0 +1,382 @@ +// zlib License +// +// Copyright (c) 2021 Dan Ferguson, Victor Hugo Soliz Kuncar, Jason Dove +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.FFmpeg; +using LanguageExt; + +namespace ErsatzTV.Core.FFmpeg +{ + internal class FFmpegProcessBuilder + { + private readonly List _arguments = new(); + private readonly Queue _audioFilters = new(); + private readonly string _ffmpegPath; + private readonly Queue _videoFilters = new(); + + public FFmpegProcessBuilder(string ffmpegPath) => _ffmpegPath = ffmpegPath; + + public FFmpegProcessBuilder WithThreads(int threads) + { + _arguments.Add("-threads"); + _arguments.Add($"{threads}"); + return this; + } + + public FFmpegProcessBuilder WithRealtimeOutput(bool realtimeOutput) + { + if (realtimeOutput) + { + _arguments.Add("-re"); + } + + return this; + } + + public FFmpegProcessBuilder WithSeek(Option maybeStart) + { + maybeStart.IfSome( + start => + { + _arguments.Add("-ss"); + _arguments.Add($"{start:c}"); + }); + + return this; + } + + public FFmpegProcessBuilder WithInfiniteLoop() + { + _arguments.Add("-stream_loop"); + _arguments.Add("-1"); + return this; + } + + public FFmpegProcessBuilder WithLoopedImage(string input) + { + _arguments.Add("-loop"); + _arguments.Add("1"); + return WithInput(input); + } + + + public FFmpegProcessBuilder WithPipe() + { + _arguments.Add("pipe:1"); + return this; + } + + public FFmpegProcessBuilder WithPixfmt(string pixfmt) + { + _arguments.Add("-pix_fmt"); + _arguments.Add(pixfmt); + return this; + } + + public FFmpegProcessBuilder WithLibavfilter() + { + _arguments.Add("-f"); + _arguments.Add("lavfi"); + return this; + } + + public FFmpegProcessBuilder WithInput(string input) + { + _arguments.Add("-i"); + _arguments.Add($"{input}"); + return this; + } + + public FFmpegProcessBuilder WithFiltergraph(string graph) + { + _arguments.Add("-vf"); + _arguments.Add($"{graph}"); + return this; + } + + public FFmpegProcessBuilder WithFilterComplex(string filter, string finalVideo, string finalAudio) + { + _arguments.Add("-filter_complex"); + _arguments.Add($"{filter}"); + _arguments.Add("-map"); + _arguments.Add(finalVideo); + _arguments.Add("-map"); + _arguments.Add(finalAudio); + return this; + } + + public FFmpegProcessBuilder WithConcat(string concatPlaylist) + { + var arguments = new List + { + "-f", "concat", + "-safe", "0", + "-protocol_whitelist", "file,http,tcp,https,tcp,tls", + "-probesize", "32", + "-i", concatPlaylist, + "-map", "0:v", + "-map", "0:a", + "-c", "copy", + "-muxdelay", "0", + "-muxpreload", "0" + }; + _arguments.AddRange(arguments); + return this; + } + + public FFmpegProcessBuilder WithMetadata(Channel channel) + { + var arguments = new List + { + "-metadata", "service_provider=\"ErsatzTV\"", + "-metadata", $"service_name=\"{channel.Name}\"" + }; + _arguments.AddRange(arguments); + return this; + } + + public FFmpegProcessBuilder WithFormatFlags(IEnumerable formatFlags) + { + _arguments.Add("-fflags"); + _arguments.Add(string.Join(string.Empty, formatFlags)); + return this; + } + + public FFmpegProcessBuilder WithText(string text) + { + const string FONT_FILE = "fontfile=Resources/Roboto-Regular.ttf"; + const string FONT_SIZE = "fontsize=30"; + const string FONT_COLOR = "fontcolor=white"; + const string X = "x=(w-text_w)/2"; + const string Y = "y=(h-text_h)/2"; + + return WithFiltergraph($"drawtext={FONT_FILE}:{FONT_SIZE}:{FONT_COLOR}:{X}:{Y}:text='{text}'"); + } + + public FFmpegProcessBuilder WithDuration(TimeSpan duration) => + // _arguments.Add("-t"); + // _arguments.Add($"{duration:c}"); + this; + + public FFmpegProcessBuilder WithFormat(string format) + { + _arguments.Add("-f"); + _arguments.Add($"{format}"); + return this; + } + + public FFmpegProcessBuilder WithPlaybackArgs(FFmpegPlaybackSettings playbackSettings) + { + var arguments = new List + { + "-c:v", playbackSettings.VideoCodec, + "-flags", "cgop", + "-sc_threshold", "1000000000" + }; + + string[] videoBitrateArgs = playbackSettings.VideoBitrate.Match( + bitrate => + new[] + { + "-b:v", $"{bitrate}k", + "-maxrate:v", $"{bitrate}k" + }, + Array.Empty()); + arguments.AddRange(videoBitrateArgs); + + playbackSettings.VideoBufferSize + .IfSome(bufferSize => arguments.AddRange(new[] { "-bufsize:v", $"{bufferSize}k" })); + + string[] audioBitrateArgs = playbackSettings.AudioBitrate.Match( + bitrate => + new[] + { + "-b:a", $"{bitrate}k", + "-maxrate:a", $"{bitrate}k" + }, + Array.Empty()); + arguments.AddRange(audioBitrateArgs); + + playbackSettings.AudioBufferSize + .IfSome(bufferSize => arguments.AddRange(new[] { "-bufsize:a", $"{bufferSize}k" })); + + playbackSettings.AudioChannels + .IfSome(channels => arguments.AddRange(new[] { "-ac", $"{channels}" })); + + playbackSettings.AudioSampleRate + .IfSome(sampleRate => arguments.AddRange(new[] { "-ar", $"{sampleRate}k" })); + + arguments.AddRange( + new[] + { + "-c:a", playbackSettings.AudioCodec, + "-map_metadata", "-1", + "-movflags", "+faststart", + "-muxdelay", "0", + "-muxpreload", "0" + }); + + _arguments.AddRange(arguments); + return this; + } + + public FFmpegProcessBuilder WithScaling(IDisplaySize displaySize, string algorithm) + { + _videoFilters.Enqueue($"scale={displaySize.Width}:{displaySize.Height}:flags={algorithm}"); + return this; + } + + public FFmpegProcessBuilder WithBlackBars(IDisplaySize displaySize) + { + _videoFilters.Enqueue($"pad={displaySize.Width}:{displaySize.Height}:(ow-iw)/2:(oh-ih)/2"); + return this; + } + + public FFmpegProcessBuilder WithAlignedAudio(Option audioDuration) + { + audioDuration.IfSome(duration => _audioFilters.Enqueue($"apad=whole_dur={duration.TotalMilliseconds}ms")); + return this; + } + + public FFmpegProcessBuilder WithDeinterlace(bool deinterlace, string algorithm = "yadif=1") + { + if (deinterlace) + { + _videoFilters.Enqueue(algorithm); + } + + return this; + } + + public FFmpegProcessBuilder WithSAR() + { + // TODO: minsiz? + _videoFilters.Enqueue("setsar=1"); + return this; + } + + public FFmpegProcessBuilder WithFilterComplex() + { + var complexFilter = new StringBuilder(); + var videoLabel = "0:v"; + var audioLabel = "0:a"; + bool hasVideoFilters = _videoFilters.Any(); + if (hasVideoFilters) + { + (string filter, string finalLabel) = GenerateFilter(_videoFilters, StreamType.Video); + complexFilter.Append(filter); + videoLabel = finalLabel; + } + + if (_audioFilters.Any()) + { + if (hasVideoFilters) + { + complexFilter.Append(';'); + } + + (string filter, string finalLabel) = GenerateFilter(_audioFilters, StreamType.Audio); + complexFilter.Append(filter); + audioLabel = finalLabel; + } + + var complex = complexFilter.ToString(); + + if (!string.IsNullOrWhiteSpace(complex)) + { + _arguments.Add("-filter_complex"); + _arguments.Add(complex); + } + + _arguments.Add("-map"); + _arguments.Add(videoLabel); + + _arguments.Add("-map"); + _arguments.Add(audioLabel); + + return this; + } + + public FFmpegProcessBuilder WithQuiet() + { + _arguments.AddRange(new[] { "-hide_banner", "-loglevel", "panic", "-nostats" }); + return this; + } + + public Process Build() + { + var startInfo = new ProcessStartInfo + { + FileName = _ffmpegPath, + RedirectStandardOutput = true, + RedirectStandardError = false, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8 + }; + + foreach (string argument in _arguments) + { + startInfo.ArgumentList.Add(argument); + } + + return new Process + { + StartInfo = startInfo + }; + } + + private FilterResult GenerateFilter(Queue filterQueue, StreamType streamType) + { + var filter = new StringBuilder(); + var index = 0; + string nullFilter = streamType switch + { + StreamType.Audio => "anull", + StreamType.Video => "null" + }; + char av = streamType switch + { + StreamType.Audio => 'a', + StreamType.Video => 'v' + }; + filter.Append($"[0:{av}]{nullFilter}[{av}{index}]"); + while (filterQueue.TryDequeue(out string result)) + { + filter.Append($";[{av}{index}]{result}[{av}{++index}]"); + } + + return new FilterResult(filter.ToString(), $"[{av}{index}]"); + } + + private record FilterResult(string Filter, string FinalLabel); + + private enum StreamType + { + Audio, + Video + } + } +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs new file mode 100644 index 000000000..cfb134731 --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -0,0 +1,132 @@ +using System; +using System.Diagnostics; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.FFmpeg; + +namespace ErsatzTV.Core.FFmpeg +{ + public class FFmpegProcessService + { + private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator; + + public FFmpegProcessService(FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService) => + _playbackSettingsCalculator = ffmpegPlaybackSettingsService; + + public Process ForPlayoutItem( + string ffmpegPath, + Channel channel, + PlayoutItem item, + DateTimeOffset now) + { + FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings( + channel.StreamingMode, + channel.FFmpegProfile, + item, + now); + + FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath) + .WithThreads(playbackSettings.ThreadCount) + .WithQuiet() + .WithFormatFlags(playbackSettings.FormatFlags) + .WithRealtimeOutput(playbackSettings.RealtimeOutput) + .WithSeek(playbackSettings.StreamSeek) + .WithInput(item.MediaItem.Path); + + playbackSettings.ScaledSize.Match( + scaledSize => + { + builder = builder.WithDeinterlace(playbackSettings.Deinterlace) + .WithScaling(scaledSize, playbackSettings.ScalingAlgorithm) + .WithSAR(); + + scaledSize = scaledSize.PadToEven(); + if (NeedToPad(channel.FFmpegProfile.Resolution, scaledSize)) + { + builder = builder.WithBlackBars(channel.FFmpegProfile.Resolution); + } + + builder = builder + .WithAlignedAudio(playbackSettings.AudioDuration).WithFilterComplex(); + }, + () => + { + if (playbackSettings.PadToDesiredResolution) + { + builder = builder + .WithDeinterlace(playbackSettings.Deinterlace) + .WithSAR() + .WithBlackBars(channel.FFmpegProfile.Resolution) + .WithAlignedAudio(playbackSettings.AudioDuration) + .WithFilterComplex(); + } + else if (playbackSettings.Deinterlace) + { + builder = builder.WithDeinterlace(playbackSettings.Deinterlace) + .WithAlignedAudio(playbackSettings.AudioDuration) + .WithFilterComplex(); + } + else + { + builder = builder + .WithAlignedAudio(playbackSettings.AudioDuration) + .WithFilterComplex(); + } + }); + + return builder.WithPlaybackArgs(playbackSettings) + .WithMetadata(channel) + .WithFormat("mpegts") + .WithDuration(item.Start + item.MediaItem.Metadata.Duration - now) + .WithPipe() + .Build(); + } + + public Process ForOfflineImage(string ffmpegPath, Channel channel) + { + FFmpegPlaybackSettings playbackSettings = + _playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile); + + IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution; + + return new FFmpegProcessBuilder(ffmpegPath) + .WithThreads(1) + .WithQuiet() + .WithFormatFlags(playbackSettings.FormatFlags) + .WithRealtimeOutput(playbackSettings.RealtimeOutput) + .WithLoopedImage("Resources/background.png") + .WithLibavfilter() + .WithInput("anullsrc") + .WithFilterComplex( + $"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height}[video]", + "[video]", + "1:a") + .WithPixfmt("yuv420p") + .WithPlaybackArgs(playbackSettings) + .WithMetadata(channel) + .WithFormat("mpegts") + .WithDuration(TimeSpan.FromSeconds(10)) // TODO: figure out when we're back online + .WithPipe() + .Build(); + } + + public Process ConcatChannel(string ffmpegPath, Channel channel, string scheme, string host) + { + FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings; + + return new FFmpegProcessBuilder(ffmpegPath) + .WithThreads(1) + .WithQuiet() + .WithFormatFlags(playbackSettings.FormatFlags) + .WithRealtimeOutput(playbackSettings.RealtimeOutput) + .WithInfiniteLoop() + .WithConcat($"{scheme}://{host}/ffmpeg/concat/{channel.Number}") + .WithMetadata(channel) + .WithFormat("mpegts") + .WithPipe() + .Build(); + } + + private bool NeedToPad(IDisplaySize target, IDisplaySize displaySize) => + displaySize.Width != target.Width || displaySize.Height != target.Height; + } +} diff --git a/ErsatzTV.Core/FileSystemLayout.cs b/ErsatzTV.Core/FileSystemLayout.cs new file mode 100644 index 000000000..1e811e2d3 --- /dev/null +++ b/ErsatzTV.Core/FileSystemLayout.cs @@ -0,0 +1,18 @@ +using System; +using System.IO; + +namespace ErsatzTV.Core +{ + public static class FileSystemLayout + { + public static readonly string AppDataFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ersatztv"); + + public static readonly string DatabasePath = Path.Combine(AppDataFolder, "ersatztv.sqlite3"); + + public static readonly string ImageCacheFolder = Path.Combine(AppDataFolder, "cache", "images"); + + public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json"); + } +} diff --git a/ErsatzTV.Core/Hdhr/DeviceXml.cs b/ErsatzTV.Core/Hdhr/DeviceXml.cs new file mode 100644 index 000000000..a8a5fae9b --- /dev/null +++ b/ErsatzTV.Core/Hdhr/DeviceXml.cs @@ -0,0 +1,23 @@ +namespace ErsatzTV.Core.Hdhr +{ + public record DeviceXml(string Scheme, string Host) + { + public string ToXml() => + @$" + {Scheme}://{Host} + + 1 + 0 + + + urn:schemas-upnp-org:device:MediaServer:1 + ErsatzTV + Silicondust + HDTC-2US + HDTC-2US + + uuid:2020-03-S3LA-BG3LIA:2 + +"; + } +} diff --git a/ErsatzTV.Core/Hdhr/Discover.cs b/ErsatzTV.Core/Hdhr/Discover.cs new file mode 100644 index 000000000..ba47a55f0 --- /dev/null +++ b/ErsatzTV.Core/Hdhr/Discover.cs @@ -0,0 +1,29 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ErsatzTV.Core.Hdhr +{ + [SuppressMessage("ReSharper", "InconsistentNaming")] + public class Discover + { + private readonly string _host; + private readonly string _scheme; + + public Discover(string scheme, string host, int tunerCount) + { + _scheme = scheme; + _host = host; + TunerCount = tunerCount; + } + + public string DeviceAuth => ""; + public string DeviceID => "ErsatzTV"; + public string FirmwareName => "hdhomeruntc_atsc"; + public string FirmwareVersion => "20190621"; + public string FriendlyName => "ErsatzTV"; + public string LineupURL => $"{_scheme}://{_host}/lineup.json"; + public string Manufacturer => "ErsatzTV - Silicondust"; + public string ManufacturerURL => "https://github.com/jasongdove/ErsatzTV"; + public string ModelNumber => "HDTC-2US"; + public int TunerCount { get; } + } +} diff --git a/ErsatzTV.Core/Hdhr/LineupItem.cs b/ErsatzTV.Core/Hdhr/LineupItem.cs new file mode 100644 index 000000000..ca2fa35c7 --- /dev/null +++ b/ErsatzTV.Core/Hdhr/LineupItem.cs @@ -0,0 +1,24 @@ +using System.Diagnostics.CodeAnalysis; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Hdhr +{ + [SuppressMessage("ReSharper", "InconsistentNaming")] + public class LineupItem + { + private readonly Channel _channel; + private readonly string _host; + private readonly string _scheme; + + public LineupItem(string scheme, string host, Channel channel) + { + _scheme = scheme; + _host = host; + _channel = channel; + } + + public string GuideNumber => _channel.Number.ToString(); + public string GuideName => _channel.Name; + public string URL => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}"; + } +} diff --git a/ErsatzTV.Core/Hdhr/LineupStatus.cs b/ErsatzTV.Core/Hdhr/LineupStatus.cs new file mode 100644 index 000000000..1469f5a70 --- /dev/null +++ b/ErsatzTV.Core/Hdhr/LineupStatus.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Core.Hdhr +{ + public class LineupStatus + { + public int ScanInProgress = 0; + public int ScanPossible = 1; + public string Source = "Cable"; + public IEnumerable SourceList = new[] { "Cable" }; + } +} diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IDisplaySize.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IDisplaySize.cs new file mode 100644 index 000000000..7b1562fbe --- /dev/null +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IDisplaySize.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Interfaces.FFmpeg +{ + public interface IDisplaySize + { + public int Width { get; } + public int Height { get; } + } +} diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegLocator.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegLocator.cs new file mode 100644 index 000000000..04a18a225 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegLocator.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.FFmpeg +{ + public interface IFFmpegLocator + { + public Task> ValidatePath(string executableBase, ConfigElementKey key); + } +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs new file mode 100644 index 000000000..c0d87b6b8 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Metadata +{ + public interface ILocalMediaScanner + { + Task ScanLocalMediaSource(LocalMediaSource localMediaSource, string ffprobePath); + } +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs new file mode 100644 index 000000000..eea22c7af --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Interfaces.Metadata +{ + public interface ILocalMetadataProvider + { + Task RefreshMetadata(MediaItem mediaItem); + } +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs new file mode 100644 index 000000000..12b49d93a --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Interfaces.Metadata +{ + public interface ILocalStatisticsProvider + { + Task RefreshStatistics(string ffprobePath, MediaItem mediaItem); + } +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs b/ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs new file mode 100644 index 000000000..79e83ee34 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Interfaces.Metadata +{ + public interface ISmartCollectionBuilder + { + Task RefreshSmartCollections(MediaItem mediaItem); + } +} diff --git a/ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs b/ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs new file mode 100644 index 000000000..f3a3bdf29 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Plex; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Plex +{ + public interface IPlexSecretStore + { + public Task GetClientIdentifier(); + public Task> GetUserAuthTokens(); + public Task UpsertUserAuthToken(PlexUserAuthToken userAuthToken); + public Task> GetServerAuthTokens(); + public Task> GetServerAuthToken(string clientIdentifier); + public Task UpsertServerAuthToken(PlexServerAuthToken serverAuthToken); + } +} diff --git a/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs b/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs new file mode 100644 index 000000000..e933d3319 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Plex; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Plex +{ + public interface IPlexServerApiClient + { + Task>> GetLibraries( + PlexMediaSourceConnection connection, + PlexServerAuthToken token); + } +} diff --git a/ErsatzTV.Core/Interfaces/Plex/IPlexTvApiClient.cs b/ErsatzTV.Core/Interfaces/Plex/IPlexTvApiClient.cs new file mode 100644 index 000000000..c71f29965 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Plex/IPlexTvApiClient.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Plex; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Plex +{ + public interface IPlexTvApiClient + { + Task> StartPinFlow(); + Task TryCompletePinFlow(PlexAuthPin authPin); + Task>> GetServers(); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs new file mode 100644 index 000000000..14b3954c8 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface IChannelRepository + { + public Task Add(Channel channel); + public Task> Get(int id); + public Task> GetByNumber(int number); + public Task> GetAll(); + public Task> GetAllForGuide(); + public Task Update(Channel channel); + public Task Delete(int channelId); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IConfigElementRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IConfigElementRepository.cs new file mode 100644 index 000000000..087e62cc0 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IConfigElementRepository.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface IConfigElementRepository + { + public Task Add(ConfigElement configElement); + public Task> Get(ConfigElementKey key); + public Task> GetValue(ConfigElementKey key); + public Task Update(ConfigElement configElement); + public Task Delete(ConfigElement configElement); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IFFmpegProfileRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IFFmpegProfileRepository.cs new file mode 100644 index 000000000..ac4962ee9 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IFFmpegProfileRepository.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface IFFmpegProfileRepository + { + public Task Add(FFmpegProfile ffmpegProfile); + public Task> Get(int id); + public Task> GetAll(); + public Task Update(FFmpegProfile ffmpegProfile); + public Task Delete(int ffmpegProfileId); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs new file mode 100644 index 000000000..4ce9801c2 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.AggregateModels; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface IMediaCollectionRepository + { + public Task Add(SimpleMediaCollection collection); + public Task> Get(int id); + public Task> GetSimpleMediaCollection(int id); + public Task> GetTelevisionMediaCollection(int id); + public Task> GetSimpleMediaCollections(); + public Task> GetAll(); + public Task> GetSummaries(string searchString); + public Task>> GetItems(int id); + public Task>> GetSimpleMediaCollectionItems(int id); + public Task>> GetTelevisionMediaCollectionItems(int id); + public Task Update(SimpleMediaCollection collection); + public Task InsertOrIgnore(TelevisionMediaCollection collection); + public Task ReplaceItems(int collectionId, List mediaItems); + public Task Delete(int mediaCollectionId); + public Task DeleteEmptyTelevisionCollections(); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs new file mode 100644 index 000000000..93df278cc --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface IMediaItemRepository + { + public Task Add(MediaItem mediaItem); + public Task> Get(int id); + public Task> GetAll(); + public Task> GetAll(MediaType mediaType); + public Task> GetAllByMediaSourceId(int mediaSourceId); + public Task Update(MediaItem mediaItem); + public Task Delete(int mediaItemId); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs new file mode 100644 index 000000000..e5246b907 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface IMediaSourceRepository + { + public Task Add(LocalMediaSource localMediaSource); + public Task Add(PlexMediaSource plexMediaSource); + public Task> GetAll(); + public Task> GetAllPlex(); + public Task> Get(int id); + public Task> GetPlex(int id); + public Task CountMediaItems(int id); + public Task Update(PlexMediaSource plexMediaSource); + public Task Delete(int id); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IPlayoutRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IPlayoutRepository.cs new file mode 100644 index 000000000..357fb2089 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IPlayoutRepository.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface IPlayoutRepository + { + public Task Add(Playout playout); + public Task> Get(int id); + public Task> GetFull(int id); + public Task> GetPlayoutItem(int channelId, DateTimeOffset now); + public Task> GetPlayoutItems(int playoutId); + public Task> GetPlayoutIdsForMediaItems(Seq mediaItems); + public Task> GetAll(); + public Task Update(Playout playout); + public Task Delete(int playoutId); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IProgramScheduleRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IProgramScheduleRepository.cs new file mode 100644 index 000000000..098538fcc --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IProgramScheduleRepository.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface IProgramScheduleRepository + { + public Task Add(ProgramSchedule programSchedule); + public Task> Get(int id); + public Task> GetWithPlayouts(int id); + public Task> GetAll(); + public Task Update(ProgramSchedule programSchedule); + public Task Delete(int programScheduleId); + public Task>> GetItems(int programScheduleId); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IResolutionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IResolutionRepository.cs new file mode 100644 index 000000000..c9f2fd6c0 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IResolutionRepository.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface IResolutionRepository + { + public Task> Get(int id); + public Task> GetAll(); + } +} diff --git a/ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs b/ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs new file mode 100644 index 000000000..fc0c37bb6 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Scheduling +{ + public interface IMediaCollectionEnumerator + { + MediaCollectionEnumeratorState State { get; } + public Option Current { get; } + public Option Peek { get; } + public void MoveNext(); + } +} diff --git a/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutBuilder.cs b/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutBuilder.cs new file mode 100644 index 000000000..f33cce3b5 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutBuilder.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Interfaces.Scheduling +{ + public interface IPlayoutBuilder + { + public Task BuildPlayoutItems(Playout playout, bool rebuild = false); + + public Task BuildPlayoutItems( + Playout playout, + DateTimeOffset start, + DateTimeOffset finish, + bool rebuild = false); + } +} diff --git a/ErsatzTV.Core/Iptv/ChannelGuide.cs b/ErsatzTV.Core/Iptv/ChannelGuide.cs new file mode 100644 index 000000000..ea2d99839 --- /dev/null +++ b/ErsatzTV.Core/Iptv/ChannelGuide.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using ErsatzTV.Core.Domain; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Iptv +{ + public class ChannelGuide + { + private readonly List _channels; + private readonly string _host; + private readonly string _scheme; + + public ChannelGuide(string scheme, string host, List channels) + { + _scheme = scheme; + _host = host; + _channels = channels; + } + + public string ToXml() + { + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + + foreach (Channel channel in _channels) + { + sb.AppendLine($""); + sb.AppendLine($"{channel.Name}"); + sb.AppendLine( + !string.IsNullOrWhiteSpace(channel.Logo) + ? $"" + : $""); + + sb.AppendLine(""); + } + + foreach (Channel channel in _channels) + { + foreach (PlayoutItem playoutItem in channel.Playouts.Collect(p => p.Items).OrderBy(i => i.Start)) + { + string start = playoutItem.Start.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty); + string stop = playoutItem.Finish.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty); + MediaMetadata metadata = Optional(playoutItem.MediaItem.Metadata).IfNone( + new MediaMetadata + { + Title = Path.GetFileName(playoutItem.MediaItem.Path) + }); + + sb.AppendLine( + $""); + sb.AppendLine($"{metadata.Title}"); + sb.AppendLine(""); + sb.AppendLine(""); + + int season = Optional(metadata.SeasonNumber).IfNone(0); + int episode = Optional(metadata.EpisodeNumber).IfNone(0); + if (season > 0 && episode > 0) + { + sb.AppendLine($"{season - 1}.{episode - 1}.0/1"); + } + + // sb.AppendLine(""); + sb.AppendLine($"{metadata.Description}"); + + if (!string.IsNullOrWhiteSpace(metadata.ContentRating)) + { + sb.AppendLine(""); + sb.AppendLine($"{metadata.ContentRating}"); + sb.AppendLine(""); + } + + sb.AppendLine(""); + } + } + + sb.AppendLine(""); + + + return sb.ToString(); + } + } +} diff --git a/ErsatzTV.Core/Iptv/ChannelPlaylist.cs b/ErsatzTV.Core/Iptv/ChannelPlaylist.cs new file mode 100644 index 000000000..933635f05 --- /dev/null +++ b/ErsatzTV.Core/Iptv/ChannelPlaylist.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Text; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Iptv +{ + public class ChannelPlaylist + { + private readonly List _channels; + private readonly string _host; + private readonly string _scheme; + + public ChannelPlaylist(string scheme, string host, List channels) + { + _scheme = scheme; + _host = host; + _channels = channels; + } + + public string ToM3U() + { + var sb = new StringBuilder(); + + var xmltv = $"{_scheme}://{_host}/iptv/xmltv.xml"; + sb.AppendLine($"#EXTM3U url-tvg=\"{xmltv}\" x-tvg-url=\"{xmltv}\""); + foreach (Channel channel in _channels) + { + string logo = !string.IsNullOrWhiteSpace(channel.Logo) + ? $"{_scheme}://{_host}/iptv/images/{channel.Logo}" + : $"{_scheme}://{_host}/images/ersatztv-500.png"; + + string shortUniqueId = Convert.ToBase64String(channel.UniqueId.ToByteArray()) + .TrimEnd('=') + .Replace("/", "_") + .Replace("+", "-"); + + string format = channel.StreamingMode switch + { + StreamingMode.HttpLiveStreaming => "m3u8", + _ => "ts" + }; + + sb.AppendLine( + $"#EXTINF:0 tvg-id=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"ErsatzTV\", {channel.Name}"); + sb.AppendLine($"{_scheme}://{_host}/iptv/channel/{channel.Number}.{format}"); + } + + return sb.ToString(); + } + } +} diff --git a/ErsatzTV.Core/LanguageExtensions.cs b/ErsatzTV.Core/LanguageExtensions.cs new file mode 100644 index 000000000..537c1756e --- /dev/null +++ b/ErsatzTV.Core/LanguageExtensions.cs @@ -0,0 +1,16 @@ +using System.Threading.Tasks; +using LanguageExt; + +namespace ErsatzTV.Core +{ + public static class LanguageExtensions + { + public static Either ToEither(this Validation validation) => + validation.ToEither().MapLeft(errors => errors.Join()); + + public static Task> ToEitherAsync(this Validation> validation) => + validation.ToEither() + .MapLeft(errors => errors.Join()) + .MapAsync, TR>(e => e); + } +} diff --git a/ErsatzTV.Core/Metadata/LocalMediaScanner.cs b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs new file mode 100644 index 000000000..315648760 --- /dev/null +++ b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Scheduling; +using LanguageExt; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Metadata +{ + public class LocalMediaScanner : ILocalMediaScanner + { + private readonly ILocalMetadataProvider _localMetadataProvider; + private readonly ILocalStatisticsProvider _localStatisticsProvider; + private readonly ILogger _logger; + private readonly IMediaItemRepository _mediaItemRepository; + private readonly IPlayoutBuilder _playoutBuilder; + private readonly IPlayoutRepository _playoutRepository; + private readonly ISmartCollectionBuilder _smartCollectionBuilder; + + public LocalMediaScanner( + IMediaItemRepository mediaItemRepository, + IPlayoutRepository playoutRepository, + ILocalStatisticsProvider localStatisticsProvider, + ILocalMetadataProvider localMetadataProvider, + ISmartCollectionBuilder smartCollectionBuilder, + IPlayoutBuilder playoutBuilder, + ILogger logger) + { + _mediaItemRepository = mediaItemRepository; + _playoutRepository = playoutRepository; + _localStatisticsProvider = localStatisticsProvider; + _localMetadataProvider = localMetadataProvider; + _smartCollectionBuilder = smartCollectionBuilder; + _playoutBuilder = playoutBuilder; + _logger = logger; + } + + public async Task ScanLocalMediaSource(LocalMediaSource localMediaSource, string ffprobePath) + { + if (!Directory.Exists(localMediaSource.Folder)) + { + _logger.LogWarning( + "Media source folder {Folder} does not exist; skipping scan", + localMediaSource.Folder); + return Unit.Default; + } + + List knownMediaItems = await _mediaItemRepository.GetAllByMediaSourceId(localMediaSource.Id); + var modifiedPlayoutIds = new List(); + + // remove files that no longer exist + // add new files + // refresh metadata for any files where it is missing + var knownExtensions = new List + { + ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", ".m4p", ".m4v", + ".avi", ".wmv", ".mov", ".mkv" + }; + + var allFiles = Directory.GetFiles(localMediaSource.Folder, "*", SearchOption.AllDirectories) + .Filter(file => knownExtensions.Contains(Path.GetExtension(file))) + .ToSeq(); + + // check if the media item exists + (Seq newFiles, Seq existingMediaItems) = allFiles.Map( + s => Optional(knownMediaItems.Find(i => i.Path == s)).ToEither(s)) + .Partition(); + + // TODO: flag as missing? delete after some period of time? + var removedMediaItems = knownMediaItems.Filter(i => !allFiles.Contains(i.Path)).ToSeq(); + modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(removedMediaItems)); + foreach (MediaItem mediaItem in removedMediaItems) + { + _logger.LogDebug("Removing missing local media item {MediaItem}", mediaItem.Path); + await _mediaItemRepository.Delete(mediaItem.Id); + } + + // if exists, check if the file was modified + Seq modifiedMediaItems = existingMediaItems.Filter( + mediaItem => + { + DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path); + bool modified = lastWrite > mediaItem.LastWriteTime.IfNone(DateTime.MinValue); + return modified || mediaItem.Metadata == null; + }); + modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(modifiedMediaItems)); + foreach (MediaItem mediaItem in modifiedMediaItems) + { + _logger.LogDebug("Refreshing metadata for media item {MediaItem}", mediaItem.Path); + await RefreshMetadata(mediaItem, ffprobePath); + } + + // if new, add and store mtime, refresh metadata + var addedMediaItems = new Seq(); + foreach (string path in newFiles) + { + _logger.LogDebug("Adding new media item {MediaItem}", path); + var mediaItem = new MediaItem + { + MediaSourceId = localMediaSource.Id, + Path = path, + LastWriteTime = File.GetLastWriteTimeUtc(path) + }; + + await _mediaItemRepository.Add(mediaItem); + await RefreshMetadata(mediaItem, ffprobePath); + addedMediaItems.Add(mediaItem); + } + + modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(addedMediaItems)); + + foreach (int playoutId in modifiedPlayoutIds.Distinct()) + { + Option maybePlayout = await _playoutRepository.GetFull(playoutId); + await maybePlayout.Match( + async playout => + { + Playout result = await _playoutBuilder.BuildPlayoutItems(playout, true); + await _playoutRepository.Update(result); + }, + Task.CompletedTask); + } + + return Unit.Default; + } + + private async Task RefreshMetadata(MediaItem mediaItem, string ffprobePath) + { + await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); + await _localMetadataProvider.RefreshMetadata(mediaItem); + await _smartCollectionBuilder.RefreshSmartCollections(mediaItem); + } + } +} diff --git a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs new file mode 100644 index 000000000..334a6bfeb --- /dev/null +++ b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs @@ -0,0 +1,179 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Xml.Serialization; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Metadata +{ + public class LocalMetadataProvider : ILocalMetadataProvider + { + private readonly IMediaItemRepository _mediaItemRepository; + + public LocalMetadataProvider(IMediaItemRepository mediaItemRepository) => + _mediaItemRepository = mediaItemRepository; + + public async Task RefreshMetadata(MediaItem mediaItem) + { + Option maybeMetadata = await LoadMetadata(mediaItem); + MediaMetadata metadata = maybeMetadata.IfNone(() => GetFallbackMetadata(mediaItem)); + await ApplyMetadataUpdate(mediaItem, metadata); + } + + private async Task ApplyMetadataUpdate(MediaItem mediaItem, MediaMetadata metadata) + { + if (mediaItem.Metadata == null) + { + mediaItem.Metadata = new MediaMetadata(); + } + + mediaItem.Metadata.MediaType = metadata.MediaType; + mediaItem.Metadata.Title = metadata.Title; + mediaItem.Metadata.Subtitle = metadata.Subtitle; + mediaItem.Metadata.Description = metadata.Description; + mediaItem.Metadata.EpisodeNumber = metadata.EpisodeNumber; + mediaItem.Metadata.SeasonNumber = metadata.SeasonNumber; + mediaItem.Metadata.Aired = metadata.Aired; + mediaItem.Metadata.ContentRating = metadata.ContentRating; + + await _mediaItemRepository.Update(mediaItem); + } + + private async Task> LoadMetadata(MediaItem mediaItem) + { + string nfoFileName = Path.ChangeExtension(mediaItem.Path, "nfo"); + if (nfoFileName == null || !File.Exists(nfoFileName)) + { + return None; + } + + var tvShowSerializer = new XmlSerializer(typeof(TvShowEpisodeNfo)); + var movieSerializer = new XmlSerializer(typeof(MovieNfo)); + + TryAsync tvShowAttempt = TryAsync( + async () => + { + await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open); + return tvShowSerializer.Deserialize(fileStream); + }); + TryAsync movieAttempt = TryAsync( + async () => + { + await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open); + return movieSerializer.Deserialize(fileStream); + }); + return await choice(tvShowAttempt, movieAttempt).Match>( + result => + { + switch (result) + { + case TvShowEpisodeNfo nfo: + return new MediaMetadata + { + MediaType = MediaType.TvShow, + Title = nfo.ShowTitle, + Subtitle = nfo.Title, + Description = nfo.Outline, + EpisodeNumber = nfo.Episode, + SeasonNumber = nfo.Season, + Aired = GetAired(nfo.Aired) + }; + case MovieNfo nfo: + return new MediaMetadata + { + MediaType = MediaType.Movie, + Title = nfo.Title, + Description = nfo.Outline, + ContentRating = nfo.ContentRating, + Aired = GetAired(nfo.Premiered) + }; + default: + return None; + } + }, + None); + } + + private MediaMetadata GetFallbackMetadata(MediaItem mediaItem) + { + string fileName = Path.GetFileName(mediaItem.Path); + var metadata = new MediaMetadata { Title = fileName ?? mediaItem.Path }; + + if (fileName != null) + { + const string PATTERN = @"^(.*?)[\s-]+[sS](\d+)[eE](\d+)\.\w+$"; + Match match = Regex.Match(fileName, PATTERN); + if (match.Success) + { + metadata.MediaType = MediaType.TvShow; + metadata.Title = match.Groups[1].Value; + metadata.SeasonNumber = int.Parse(match.Groups[2].Value); + metadata.EpisodeNumber = int.Parse(match.Groups[3].Value); + } + } + + return metadata; + } + + private static DateTime? GetAired(string aired) + { + if (string.IsNullOrWhiteSpace(aired)) + { + return null; + } + + if (DateTime.TryParse(aired, out DateTime parsed)) + { + return parsed; + } + + return null; + } + + [XmlRoot("movie")] + public class MovieNfo + { + [XmlElement("title")] + public string Title { get; set; } + + [XmlElement("outline")] + public string Outline { get; set; } + + [XmlElement("mpaa")] + public string ContentRating { get; set; } + + [XmlElement("premiered")] + public string Premiered { get; set; } + } + + [XmlRoot("episodedetails")] + public class TvShowEpisodeNfo + { + [XmlElement("showtitle")] + public string ShowTitle { get; set; } + + [XmlElement("title")] + public string Title { get; set; } + + [XmlElement("outline")] + public string Outline { get; set; } + + [XmlElement("episode")] + public int Episode { get; set; } + + [XmlElement("season")] + public int Season { get; set; } + + [XmlElement("mpaa")] + public string ContentRating { get; set; } + + [XmlElement("aired")] + public string Aired { get; set; } + } + } +} diff --git a/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs b/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs new file mode 100644 index 000000000..d41cedd4d --- /dev/null +++ b/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Newtonsoft.Json; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Metadata +{ + public class LocalStatisticsProvider : ILocalStatisticsProvider + { + private readonly IMediaItemRepository _mediaItemRepository; + + public LocalStatisticsProvider(IMediaItemRepository mediaItemRepository) => + _mediaItemRepository = mediaItemRepository; + + public async Task RefreshStatistics(string ffprobePath, MediaItem mediaItem) + { + FFprobe ffprobe = await GetProbeOutput(ffprobePath, mediaItem); + MediaMetadata metadata = ProjectToMediaMetadata(ffprobe); + await ApplyStatisticsUpdate(mediaItem, metadata); + } + + private async Task ApplyStatisticsUpdate( + MediaItem mediaItem, + MediaMetadata metadata) + { + if (mediaItem.Metadata == null) + { + mediaItem.Metadata = new MediaMetadata(); + } + + mediaItem.Metadata.Duration = metadata.Duration; + mediaItem.Metadata.AudioCodec = metadata.AudioCodec; + mediaItem.Metadata.SampleAspectRatio = metadata.SampleAspectRatio; + mediaItem.Metadata.DisplayAspectRatio = metadata.DisplayAspectRatio; + mediaItem.Metadata.Width = metadata.Width; + mediaItem.Metadata.Height = metadata.Height; + mediaItem.Metadata.VideoCodec = metadata.VideoCodec; + mediaItem.Metadata.VideoScanType = metadata.VideoScanType; + + await _mediaItemRepository.Update(mediaItem); + } + + private Task GetProbeOutput(string ffprobePath, MediaItem mediaItem) + { + var startInfo = new ProcessStartInfo + { + FileName = ffprobePath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + startInfo.ArgumentList.Add("-v"); + startInfo.ArgumentList.Add("quiet"); + startInfo.ArgumentList.Add("-print_format"); + startInfo.ArgumentList.Add("json"); + startInfo.ArgumentList.Add("-show_format"); + startInfo.ArgumentList.Add("-show_streams"); + startInfo.ArgumentList.Add("-i"); + startInfo.ArgumentList.Add(mediaItem.Path); + + var probe = new Process + { + StartInfo = startInfo + }; + + probe.Start(); + return probe.StandardOutput.ReadToEndAsync().MapAsync( + async output => + { + await probe.WaitForExitAsync(); + return JsonConvert.DeserializeObject(output); + }); + } + + private MediaMetadata ProjectToMediaMetadata(FFprobe probeOutput) => + Optional(probeOutput) + .Filter(json => json != null) + .ToValidation("Unable to parse ffprobe output") + .ToEither() + .Match( + json => + { + var duration = TimeSpan.FromSeconds(double.Parse(json.format.duration)); + + var metadata = new MediaMetadata { Duration = duration }; + + FFprobeStream audioStream = json.streams.FirstOrDefault(s => s.codec_type == "audio"); + if (audioStream != null) + { + metadata = metadata with + { + AudioCodec = audioStream.codec_name + }; + } + + FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video"); + if (videoStream != null) + { + metadata = metadata with + { + SampleAspectRatio = videoStream.sample_aspect_ratio, + DisplayAspectRatio = videoStream.display_aspect_ratio, + Width = videoStream.width, + Height = videoStream.height, + VideoCodec = videoStream.codec_name, + VideoScanType = ScanTypeFromFieldOrder(videoStream.field_order) + }; + } + + return metadata; + }, + _ => new MediaMetadata()); + + private VideoScanType ScanTypeFromFieldOrder(string fieldOrder) => + fieldOrder?.ToLowerInvariant() switch + { + var x when x == "tt" || x == "bb" || x == "tb" || x == "bt" => VideoScanType.Interlaced, + "progressive" => VideoScanType.Progressive, + _ => VideoScanType.Unknown + }; + + // ReSharper disable InconsistentNaming + public record FFprobe(FFprobeFormat format, List streams); + + public record FFprobeFormat(string duration); + + public record FFprobeStream( + int index, + string codec_name, + string codec_type, + int width, + int height, + string sample_aspect_ratio, + string display_aspect_ratio, + string field_order, + string r_frame_rate); + // ReSharper restore InconsistentNaming + } +} diff --git a/ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs b/ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs new file mode 100644 index 000000000..7150e968f --- /dev/null +++ b/ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Metadata +{ + public class SmartCollectionBuilder : ISmartCollectionBuilder + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + + public SmartCollectionBuilder(IMediaCollectionRepository mediaCollectionRepository) => + _mediaCollectionRepository = mediaCollectionRepository; + + public async Task RefreshSmartCollections(MediaItem mediaItem) + { + foreach (TelevisionMediaCollection collection in GetTelevisionCollections(mediaItem)) + { + await _mediaCollectionRepository.InsertOrIgnore(collection); + } + } + + private IEnumerable GetTelevisionCollections(MediaItem mediaItem) + { + IList televisionMediaItems = new[] { mediaItem } + .Where(c => c.Metadata.MediaType == MediaType.TvShow) + .ToList(); + + IEnumerable televisionShowCollections = televisionMediaItems + .Map(c => c.Metadata.Title) + .Distinct().Map( + t => new TelevisionMediaCollection + { + Name = $"{t} - All Seasons", + ShowTitle = t, + SeasonNumber = null + }); + + IEnumerable televisionShowSeasonCollections = televisionMediaItems + .Map(c => new { c.Metadata.Title, c.Metadata.SeasonNumber }).Distinct() + .Map( + ts => + { + return Optional(ts.SeasonNumber).Map( + sn => new TelevisionMediaCollection + { + Name = $"{ts.Title} - Season {sn:00}", + ShowTitle = ts.Title, + SeasonNumber = sn + }); + }) + .Sequence().Flatten(); + + return Seq(televisionShowCollections, televisionShowSeasonCollections).Flatten(); + } + } +} diff --git a/ErsatzTV.Core/Plex/PlexAuthPin.cs b/ErsatzTV.Core/Plex/PlexAuthPin.cs new file mode 100644 index 000000000..85e14f0e3 --- /dev/null +++ b/ErsatzTV.Core/Plex/PlexAuthPin.cs @@ -0,0 +1,16 @@ +namespace ErsatzTV.Core.Plex +{ + public record PlexAuthPin(int Id, string Code, string ClientIdentifier) + { + public string Url + { + get + { + var clientId = $"clientID={ClientIdentifier}"; + var code = $"code={Code}"; + var cdp = "context%5Bdevice%5D%5Bproduct%5D=ErsatzTV"; + return $"https://app.plex.tv/auth#?{clientId}&{code}&{cdp}"; + } + } + } +} diff --git a/ErsatzTV.Core/Plex/PlexServerAuthToken.cs b/ErsatzTV.Core/Plex/PlexServerAuthToken.cs new file mode 100644 index 000000000..4f16e6223 --- /dev/null +++ b/ErsatzTV.Core/Plex/PlexServerAuthToken.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Core.Plex +{ + public record PlexServerAuthToken(string ClientIdentifier, string AuthToken); +} diff --git a/ErsatzTV.Core/Plex/PlexUserAuthToken.cs b/ErsatzTV.Core/Plex/PlexUserAuthToken.cs new file mode 100644 index 000000000..4c305d2cb --- /dev/null +++ b/ErsatzTV.Core/Plex/PlexUserAuthToken.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Core.Plex +{ + public record PlexUserAuthToken(string Email, string AuthToken); +} diff --git a/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs new file mode 100644 index 000000000..a82f72e7a --- /dev/null +++ b/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Scheduling; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Scheduling +{ + public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnumerator + { + private readonly IList _sortedMediaItems; + + public ChronologicalMediaCollectionEnumerator( + IEnumerable mediaItems, + MediaCollectionEnumeratorState state) + { + _sortedMediaItems = mediaItems.OrderBy(c => c.Metadata.Aired ?? DateTime.MaxValue) + .ThenBy(c => c.Metadata.SeasonNumber) + .ThenBy(c => c.Metadata.EpisodeNumber) + .ToList(); + + State = new MediaCollectionEnumeratorState { Seed = state.Seed }; + while (State.Index < state.Index) + { + MoveNext(); + } + } + + public MediaCollectionEnumeratorState State { get; } + + public Option Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None; + + public Option Peek => _sortedMediaItems.Any() + ? _sortedMediaItems[(State.Index + 1) % _sortedMediaItems.Count] + : None; + + public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count; + } +} diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs new file mode 100644 index 000000000..c3b7aea77 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -0,0 +1,336 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Scheduling; +using LanguageExt; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; +using Map = LanguageExt.Map; + +namespace ErsatzTV.Core.Scheduling +{ + public class PlayoutBuilder : IPlayoutBuilder + { + private static readonly Random Random = new(); + private readonly ILogger _logger; + private readonly IMediaCollectionRepository _mediaCollectionRepository; + + public PlayoutBuilder( + IMediaCollectionRepository mediaCollectionRepository, + ILogger logger) + { + _mediaCollectionRepository = mediaCollectionRepository; + _logger = logger; + } + + public Task BuildPlayoutItems(Playout playout, bool rebuild = false) + { + DateTimeOffset now = DateTimeOffset.Now; + return BuildPlayoutItems(playout, now, now.AddDays(2), rebuild); + } + + public async Task BuildPlayoutItems( + Playout playout, + DateTimeOffset start, + DateTimeOffset finish, + bool rebuild = false) + { + var collections = playout.ProgramSchedule.Items.Map(i => i.MediaCollection).Distinct().ToList(); + + IEnumerable>> tuples = await collections.Map( + async collection => + { + Option> maybeItems = await _mediaCollectionRepository.GetItems(collection.Id); + return Tuple(collection, maybeItems.IfNone(new List())); + }).Sequence(); + + var collectionMediaItems = Map.createRange(tuples); + + // using IDisposable scope = _logger.BeginScope(new { PlayoutId = playout.Id }); + _logger.LogDebug( + $"{(rebuild ? "Rebuilding" : "Building")} playout {{PlayoutId}} for channel {{ChannelNumber}} - {{ChannelName}}", + playout.Id, + playout.Channel.Number, + playout.Channel.Name); + + playout.Items ??= new List(); + playout.ProgramScheduleAnchors ??= new List(); + + if (rebuild) + { + playout.Items.Clear(); + playout.Anchor = null; + playout.ProgramScheduleAnchors.Clear(); + } + + var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList(); + Map collectionEnumerators = + MapExtensions.Map(collectionMediaItems, (c, i) => GetMediaCollectionEnumerator(playout, c, i)); + + // find start anchor + PlayoutAnchor startAnchor = FindStartAnchor(playout, start, sortedScheduleItems); + + // start at the previously-decided time + DateTimeOffset currentTime = startAnchor.NextStart; + _logger.LogDebug( + "Starting playout {PlayoutId} for channel {ChannelNumber} - {ChannelName} at {StartTime}", + playout.Id, + playout.Channel.Number, + playout.Channel.Name, + currentTime); + + // start with the previously-decided schedule item + int index = sortedScheduleItems.IndexOf(startAnchor.NextScheduleItem); + + Option multipleRemaining = None; + Option durationFinish = None; + // loop until we're done filling the desired amount of time + while (currentTime < finish) + { + // get the schedule item out of the sorted list + ProgramScheduleItem scheduleItem = sortedScheduleItems[index % sortedScheduleItems.Count]; + + // find when we should start this item, based on the current time + DateTimeOffset startTime = GetStartTimeAfter( + scheduleItem, + currentTime, + multipleRemaining.IsSome, + durationFinish.IsSome); + _logger.LogDebug( + "Schedule item: {ScheduleItemNumber} / {MediaCollectionName} / {StartTime}", + scheduleItem.Index, + scheduleItem.MediaCollection.Name, + startTime); + + IMediaCollectionEnumerator enumerator = collectionEnumerators[scheduleItem.MediaCollection]; + enumerator.Current.IfSome( + mediaItem => + { + var playoutItem = new PlayoutItem + { + MediaItemId = mediaItem.Id, + Start = startTime, + Finish = startTime + mediaItem.Metadata.Duration + }; + + currentTime = startTime + mediaItem.Metadata.Duration; + enumerator.MoveNext(); + + playout.Items.Add(playoutItem); + + switch (scheduleItem) + { + case ProgramScheduleItemOne: + // only play one item from collection, so always advance to the next item + _logger.LogDebug( + "Advancing to next playout item after playout mode {PlayoutMode}", + "One"); + index++; + break; + case ProgramScheduleItemMultiple multiple: + if (multipleRemaining.IsNone) + { + multipleRemaining = multiple.Count; + } + + multipleRemaining = multipleRemaining.Map(i => i - 1); + if (multipleRemaining.IfNone(-1) == 0) + { + index++; + multipleRemaining = None; + } + + break; + case ProgramScheduleItemFlood: + enumerator.Peek.Do( + peekMediaItem => + { + ProgramScheduleItem peekScheduleItem = + sortedScheduleItems[(index + 1) % sortedScheduleItems.Count]; + DateTimeOffset peekScheduleItemStart = + peekScheduleItem.StartType == StartType.Fixed + ? GetStartTimeAfter(peekScheduleItem, currentTime) + : DateTimeOffset.MaxValue; + + // if the current time is before the next schedule item, but the current finish + // is after, we need to move on to the next schedule item + // eventually, spots probably have to fit in this gap + bool willNotFinishInTime = currentTime <= peekScheduleItemStart && + currentTime + peekMediaItem.Metadata.Duration > + peekScheduleItemStart; + if (willNotFinishInTime) + { + index++; + } + }); + break; + case ProgramScheduleItemDuration duration: + enumerator.Peek.Do( + peekMediaItem => + { + if (durationFinish.IsNone) + { + durationFinish = startTime + duration.PlayoutDuration; + } + + DateTimeOffset finish = durationFinish.IfNone(DateTime.MinValue); + bool willNotFinishInTime = currentTime <= finish && + currentTime + peekMediaItem.Metadata.Duration > + finish; + if (willNotFinishInTime) + { + index++; + + if (duration.OfflineTail) + { + durationFinish.Do(f => currentTime = f); + } + + durationFinish = None; + } + } + ); + break; + } + }); + } + + // once more to get playout anchor + ProgramScheduleItem nextScheduleItem = sortedScheduleItems[index % sortedScheduleItems.Count]; + playout.Anchor = new PlayoutAnchor + { + NextScheduleItem = nextScheduleItem, + NextScheduleItemId = nextScheduleItem.Id, + NextStart = GetStartTimeAfter(nextScheduleItem, currentTime) + }; + + // build program schedule anchors + playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators); + + // remove any items outside the desired range + playout.Items.RemoveAll(old => old.Finish < start || old.Start > finish); + + return playout; + } + + private static PlayoutAnchor FindStartAnchor( + Playout playout, + DateTimeOffset start, + IReadOnlyCollection sortedScheduleItems) => + Optional(playout.Anchor).IfNone( + () => + { + ProgramScheduleItem schedule = sortedScheduleItems.Head(); + switch (schedule.StartType) + { + case StartType.Fixed: + return new PlayoutAnchor + { + NextScheduleItem = schedule, + NextScheduleItemId = schedule.Id, + NextStart = start.Date + schedule.StartTime.GetValueOrDefault() + }; + case StartType.Dynamic: + default: + return new PlayoutAnchor + { + NextScheduleItem = schedule, + NextScheduleItemId = schedule.Id, + NextStart = start.Date + }; + } + }); + + private static DateTimeOffset GetStartTimeAfter( + ProgramScheduleItem item, + DateTimeOffset start, + bool inMultiple = false, + bool inDuration = false) + { + switch (item.StartType) + { + case StartType.Fixed: + if (item is ProgramScheduleItemMultiple && inMultiple || + item is ProgramScheduleItemDuration && inDuration) + { + return start; + } + + TimeSpan startTime = item.StartTime.GetValueOrDefault(); + DateTime result = start.Date + startTime; + // need to wrap to the next day if appropriate + return start.TimeOfDay > startTime ? result.AddDays(1) : result; + case StartType.Dynamic: + default: + return start; + } + } + + private static List BuildProgramScheduleAnchors( + Playout playout, + Map collectionEnumerators) + { + var result = new List(); + + foreach (MediaCollection collection in collectionEnumerators.Keys) + { + Option maybeExisting = playout.ProgramScheduleAnchors + .FirstOrDefault(a => a.MediaCollection == collection); + + var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State) + .ToDictionary(mcs => mcs.Key, mcs => mcs.Head()); + + PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match( + existing => + { + existing.EnumeratorState = maybeEnumeratorState[collection]; + return existing; + }, + () => new PlayoutProgramScheduleAnchor + { + Playout = playout, + PlayoutId = playout.Id, + ProgramSchedule = playout.ProgramSchedule, + ProgramScheduleId = playout.ProgramScheduleId, + MediaCollection = collection, + MediaCollectionId = collection.Id, + EnumeratorState = maybeEnumeratorState[collection] + }); + + result.Add(scheduleAnchor); + } + + return result; + } + + private static IMediaCollectionEnumerator GetMediaCollectionEnumerator( + Playout playout, + MediaCollection mediaCollection, + List mediaItems) + { + Option maybeAnchor = playout.ProgramScheduleAnchors + .FirstOrDefault( + a => a.ProgramScheduleId == playout.ProgramScheduleId && a.MediaCollectionId == mediaCollection.Id); + + MediaCollectionEnumeratorState state = maybeAnchor.Match( + anchor => anchor.EnumeratorState, + () => new MediaCollectionEnumeratorState { Seed = Random.Next(), Index = 0 }); + + switch (playout.ProgramSchedule.MediaCollectionPlaybackOrder) + { + case PlaybackOrder.Chronological: + return new ChronologicalMediaCollectionEnumerator(mediaItems, state); + case PlaybackOrder.Random: + return new RandomizedMediaCollectionEnumerator(mediaItems, state); + case PlaybackOrder.Shuffle: + return new ShuffledMediaCollectionEnumerator(mediaItems, state); + default: + // TODO: handle this error case differently? + return new RandomizedMediaCollectionEnumerator(mediaItems, state); + } + } + } +} diff --git a/ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs new file mode 100644 index 000000000..0bd39a110 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Scheduling; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Scheduling +{ + public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator + { + private readonly IList _mediaItems; + private readonly Random _random; + private int _index; + private Option _peekNext; + + public RandomizedMediaCollectionEnumerator(IList mediaItems, MediaCollectionEnumeratorState state) + { + _mediaItems = mediaItems; + _random = new Random(state.Seed); + _peekNext = None; + + State = new MediaCollectionEnumeratorState { Seed = state.Seed }; + // we want to move at least once so we start with a random item and not the first + // because _index defaults to 0 + while (State.Index <= state.Index) + { + MoveNext(); + } + } + + public MediaCollectionEnumeratorState State { get; } + + public Option Current => _mediaItems.Any() ? _mediaItems[_index] : None; + + public Option Peek + { + get + { + if (_mediaItems.Any()) + { + return _peekNext.Match( + peek => + { + Debug.WriteLine("returning existing peek"); + return _mediaItems[peek]; + }, + () => + { + Debug.WriteLine("setting peek"); + // gen a random index but save it so we can use it again when + // we actually move next + int index = _random.Next() % _mediaItems.Count; + _peekNext = index; + return _mediaItems[index]; + }); + } + + return None; + } + } + + public void MoveNext() + { + // TODO: reset seed at some predictable point so we don't overflow the index + Debug.WriteLine("resetting peek"); + + _index = _peekNext.IfNone(() => _random.Next() % _mediaItems.Count); + _peekNext = None; + + State.Index++; + } + } +} diff --git a/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs new file mode 100644 index 000000000..fe5b39572 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Scheduling; +using LanguageExt; +using LanguageExt.UnsafeValueAccess; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Scheduling +{ + public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator + { + private readonly IList _mediaItems; + private Option _peekNextSeed; + private Random _random; + private IList _shuffled; + + + public ShuffledMediaCollectionEnumerator(IList mediaItems, MediaCollectionEnumeratorState state) + { + _mediaItems = mediaItems; + _random = new Random(state.Seed); + _shuffled = Shuffle(_mediaItems, _random); + + State = new MediaCollectionEnumeratorState { Seed = state.Seed }; + while (State.Index < state.Index) + { + MoveNext(); + } + } + + public MediaCollectionEnumeratorState State { get; } + + public Option Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItems.Count] : None; + + public Option Peek + { + get + { + if (_shuffled.Any()) + { + // if we aren't peeking past the end of the list, things are simple + if (State.Index + 1 < _shuffled.Count) + { + return _shuffled[State.Index + 1]; + } + + // if we are peeking past the end of the list... + // gen a random seed but save it so we can use it again when we actually move next + Random random; + if (_peekNextSeed.IsSome) + { + random = new Random(_peekNextSeed.Value()); + } + else + { + _peekNextSeed = _random.Next(); + random = new Random(_peekNextSeed.Value()); + } + + return Shuffle(_mediaItems, random).Head(); + } + + return None; + } + } + + public void MoveNext() + { + State.Index++; + if (State.Index % _shuffled.Count == 0) + { + State.Index = 0; + State.Seed = _peekNextSeed.IfNone(_random.Next()); + _random = new Random(State.Seed); + _shuffled = Shuffle(_mediaItems, _random); + } + + State.Index %= _shuffled.Count; + _peekNextSeed = None; + } + + private static IList Shuffle(IEnumerable list, Random random) + { + T[] copy = list.ToArray(); + + int n = copy.Length; + while (n > 1) + { + n--; + int k = random.Next(n + 1); + T value = copy[k]; + copy[k] = copy[n]; + copy[n] = value; + } + + return copy; + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs new file mode 100644 index 000000000..62e0c458c --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs @@ -0,0 +1,20 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class ChannelConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(c => c.Number) + .IsUnique(); + + builder.HasMany(c => c.Playouts) // TODO: is this correct, or should we have one to one? + .WithOne(p => p.Channel) + .HasForeignKey(p => p.ChannelId) + .OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/ConfigElementConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/ConfigElementConfiguration.cs new file mode 100644 index 000000000..ca5e3bea3 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/ConfigElementConfiguration.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class ConfigElementConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.HasIndex(ce => ce.Key) + .IsUnique(); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs new file mode 100644 index 000000000..f5842dc06 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.AggregateModels; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class GenericIntegerIdConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.HasNoKey(); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/LocalMediaSourceConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/LocalMediaSourceConfiguration.cs new file mode 100644 index 000000000..2132f6d07 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/LocalMediaSourceConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class LocalMediaSourceConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("LocalMediaSources"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionConfiguration.cs new file mode 100644 index 000000000..1b4fec1b3 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class MediaCollectionConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.HasIndex(c => c.Name).IsUnique(); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionSummaryConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionSummaryConfiguration.cs new file mode 100644 index 000000000..17a64ae0e --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionSummaryConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.AggregateModels; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class MediaCollectionSummaryConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.HasNoKey(); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItemConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItemConfiguration.cs new file mode 100644 index 000000000..b0681a228 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItemConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class MediaItemConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.OwnsOne(c => c.Metadata).WithOwner(); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaSourceConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaSourceConfiguration.cs new file mode 100644 index 000000000..debf17ced --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaSourceConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class MediaSourceConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.HasIndex(ms => ms.Name).IsUnique(); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/PlayoutConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/PlayoutConfiguration.cs new file mode 100644 index 000000000..4f38fbb46 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/PlayoutConfiguration.cs @@ -0,0 +1,24 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class PlayoutConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasMany(p => p.Items) + .WithOne(pi => pi.Playout) + .HasForeignKey(pi => pi.PlayoutId) + .OnDelete(DeleteBehavior.Cascade); + + builder.OwnsOne(c => c.Anchor); + + builder.HasMany(p => p.ProgramScheduleAnchors) + .WithOne(a => a.Playout) + .HasForeignKey(a => a.PlayoutId) + .OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/PlayoutProgramScheduleAnchorConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/PlayoutProgramScheduleAnchorConfiguration.cs new file mode 100644 index 000000000..b25a39e45 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/PlayoutProgramScheduleAnchorConfiguration.cs @@ -0,0 +1,16 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class PlayoutProgramScheduleAnchorConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(a => new { a.PlayoutId, a.ProgramScheduleId, ContentGroupId = a.MediaCollectionId }); + + builder.OwnsOne(a => a.EnumeratorState); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/PlexMediaSourceConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/PlexMediaSourceConfiguration.cs new file mode 100644 index 000000000..42ec3fdc2 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/PlexMediaSourceConfiguration.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class PlexMediaSourceConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => builder.ToTable("PlexMediaSources"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/PlexMediaSourceConnectionConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/PlexMediaSourceConnectionConfiguration.cs new file mode 100644 index 000000000..01850c869 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/PlexMediaSourceConnectionConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class PlexMediaSourceConnectionConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("PlexMediaSourceConnections"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/PlexMediaSourceLibraryConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/PlexMediaSourceLibraryConfiguration.cs new file mode 100644 index 000000000..aca02c0fa --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/PlexMediaSourceLibraryConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class PlexMediaSourceLibraryConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("PlexMediaSourceLibraries"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleConfiguration.cs new file mode 100644 index 000000000..f9008782e --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleConfiguration.cs @@ -0,0 +1,24 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class ProgramScheduleConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(ps => ps.Name) + .IsUnique(); + + builder.HasMany(ps => ps.Items) + .WithOne(i => i.ProgramSchedule) + .HasForeignKey(i => i.ProgramScheduleId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(ps => ps.Playouts) + .WithOne(p => p.ProgramSchedule) + .HasForeignKey(p => p.ProgramScheduleId); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemConfiguration.cs new file mode 100644 index 000000000..08ef84828 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class ProgramScheduleItemConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("ProgramScheduleItems"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemDurationConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemDurationConfiguration.cs new file mode 100644 index 000000000..7587586ca --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemDurationConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class ProgramScheduleItemDurationConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("ProgramScheduleDurationItems"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemFloodConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemFloodConfiguration.cs new file mode 100644 index 000000000..c127ab4e3 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemFloodConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class ProgramScheduleItemFloodConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("ProgramScheduleFloodItems"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemMultipleConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemMultipleConfiguration.cs new file mode 100644 index 000000000..acc449516 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemMultipleConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class ProgramScheduleItemMultipleConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("ProgramScheduleMultipleItems"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemOneConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemOneConfiguration.cs new file mode 100644 index 000000000..5ef4b164e --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemOneConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class ProgramScheduleItemOneConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("ProgramScheduleOneItems"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/SimpleMediaCollectionConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/SimpleMediaCollectionConfiguration.cs new file mode 100644 index 000000000..66d11f585 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/SimpleMediaCollectionConfiguration.cs @@ -0,0 +1,17 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class SimpleMediaCollectionConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SimpleMediaCollections"); + + builder.HasMany(cg => cg.Items) + .WithMany(c => c.SimpleMediaCollections); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/TelevisionMediaCollectionConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionMediaCollectionConfiguration.cs new file mode 100644 index 000000000..e2345daf5 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionMediaCollectionConfiguration.cs @@ -0,0 +1,17 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class TelevisionMediaCollectionConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TelevisionMediaCollections"); + + builder.HasIndex(c => new { c.ShowTitle, c.SeasonNumber }) + .IsUnique(); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/DbInitializer.cs b/ErsatzTV.Infrastructure/Data/DbInitializer.cs new file mode 100644 index 000000000..b2d9472f8 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/DbInitializer.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Infrastructure.Data +{ + public static class DbInitializer + { + public static Unit Initialize(TvContext context) + { + if (context.Resolutions.Any()) + { + return Unit.Default; + } + + var resolutions = new List + { + new() { Id = 1, Name = "720x480", Width = 720, Height = 480 }, + new() { Id = 2, Name = "1280x720", Width = 1280, Height = 720 }, + new() { Id = 3, Name = "1920x1080", Width = 1920, Height = 1080 }, + new() { Id = 4, Name = "3840x2160", Width = 3840, Height = 2160 } + }; + context.Resolutions.AddRange(resolutions); + context.SaveChanges(); + + var resolutionConfig = new ConfigElement + { + Key = ConfigElementKey.FFmpegDefaultResolutionId.Key, + Value = "3" // 1920x1080 + }; + context.ConfigElements.Add(resolutionConfig); + context.SaveChanges(); + + var defaultProfile = FFmpegProfile.New("1920x1080 x264 ac3", resolutions[2]); + context.FFmpegProfiles.Add(defaultProfile); + context.SaveChanges(); + + var profileConfig = new ConfigElement + { + Key = ConfigElementKey.FFmpegDefaultProfileId.Key, + Value = defaultProfile.Id.ToString() + }; + context.ConfigElements.Add(profileConfig); + context.SaveChanges(); + + var defaultChannel = new Channel(Guid.NewGuid()) + { + Number = 1, + Name = "ErsatzTV", + FFmpegProfile = defaultProfile, + StreamingMode = StreamingMode.TransportStream + }; + context.Channels.Add(defaultChannel); + context.SaveChanges(); + + // TODO: clean this up + // var mediaSource = new LocalMediaSource + // { + // Name = "Default" + // }; + // context.MediaSources.Add(mediaSource); + // context.SaveChanges(); + + // TODO: create looping static image that mentions configuring via web + return Unit.Default; + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs new file mode 100644 index 000000000..bceb67e6e --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class ChannelRepository : IChannelRepository + { + private readonly TvContext _dbContext; + + public ChannelRepository(TvContext dbContext) => _dbContext = dbContext; + + public async Task Add(Channel channel) + { + await _dbContext.Channels.AddAsync(channel); + await _dbContext.SaveChangesAsync(); + return channel; + } + + public Task> Get(int id) => + _dbContext.Channels.SingleOrDefaultAsync(c => c.Id == id).Map(Optional); + + public Task> GetByNumber(int number) => + _dbContext.Channels + .Include(c => c.FFmpegProfile) + .ThenInclude(p => p.Resolution) + .SingleOrDefaultAsync(c => c.Number == number) + .Map(Optional); + + public Task> GetAll() => _dbContext.Channels.ToListAsync(); + + public Task> GetAllForGuide() => + _dbContext.Channels + .Include(c => c.Playouts) + .ThenInclude(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ToListAsync(); + + public async Task Update(Channel channel) + { + _dbContext.Channels.Update(channel); + await _dbContext.SaveChangesAsync(); + } + + public async Task Delete(int channelId) + { + Channel channel = await _dbContext.Channels.FindAsync(channelId); + _dbContext.Channels.Remove(channel); + await _dbContext.SaveChangesAsync(); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ConfigElementRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ConfigElementRepository.cs new file mode 100644 index 000000000..3ab5ba96d --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/ConfigElementRepository.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class ConfigElementRepository : IConfigElementRepository + { + private readonly TvContext _dbContext; + + public ConfigElementRepository(TvContext dbContext) => _dbContext = dbContext; + + public async Task Add(ConfigElement configElement) + { + await _dbContext.ConfigElements.AddAsync(configElement); + await _dbContext.SaveChangesAsync(); + return configElement; + } + + public async Task> Get(ConfigElementKey key) => + await _dbContext.ConfigElements.SingleOrDefaultAsync(ce => ce.Key == key.Key); + + public Task> GetValue(ConfigElementKey key) => + Get(key).MapT(ce => (T) Convert.ChangeType(ce.Value, typeof(T))); + + public async Task Update(ConfigElement configElement) + { + _dbContext.ConfigElements.Update(configElement); + await _dbContext.SaveChangesAsync(); + } + + public async Task Delete(ConfigElement configElement) + { + _dbContext.ConfigElements.Remove(configElement); + await _dbContext.SaveChangesAsync(); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/FFmpegProfileRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/FFmpegProfileRepository.cs new file mode 100644 index 000000000..6d9b8df12 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/FFmpegProfileRepository.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class FFmpegProfileRepository : IFFmpegProfileRepository + { + private readonly TvContext _dbContext; + + public FFmpegProfileRepository(TvContext dbContext) => _dbContext = dbContext; + + public async Task Add(FFmpegProfile ffmpegProfile) + { + await _dbContext.FFmpegProfiles.AddAsync(ffmpegProfile); + await _dbContext.SaveChangesAsync(); + return ffmpegProfile; + } + + public async Task> Get(int id) => + await _dbContext.FFmpegProfiles + .Include(p => p.Resolution) + .SingleOrDefaultAsync(p => p.Id == id); + + public Task> GetAll() => + _dbContext.FFmpegProfiles + .Include(p => p.Resolution) + .ToListAsync(); + + public async Task Update(FFmpegProfile ffmpegProfile) + { + _dbContext.FFmpegProfiles.Update(ffmpegProfile); + await _dbContext.SaveChangesAsync(); + } + + public async Task Delete(int ffmpegProfileId) + { + FFmpegProfile ffmpegProfile = await _dbContext.FFmpegProfiles.FindAsync(ffmpegProfileId); + _dbContext.FFmpegProfiles.Remove(ffmpegProfile); + await _dbContext.SaveChangesAsync(); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs new file mode 100644 index 000000000..7d2052a17 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs @@ -0,0 +1,142 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.AggregateModels; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class MediaCollectionRepository : IMediaCollectionRepository + { + private readonly TvContext _dbContext; + + public MediaCollectionRepository(TvContext dbContext) => _dbContext = dbContext; + + public async Task Add(SimpleMediaCollection collection) + { + await _dbContext.SimpleMediaCollections.AddAsync(collection); + await _dbContext.SaveChangesAsync(); + return collection; + } + + public Task> Get(int id) => + _dbContext.MediaCollections.SingleOrDefaultAsync(c => c.Id == id).Map(Optional); + + public Task> GetSimpleMediaCollection(int id) => + Get(id).Map(c => c.OfType().HeadOrNone()); + + public Task> GetTelevisionMediaCollection(int id) => + Get(id).Map(c => c.OfType().HeadOrNone()); + + public Task> GetSimpleMediaCollections() => + _dbContext.SimpleMediaCollections.ToListAsync(); + + public Task> GetAll() => + _dbContext.MediaCollections.ToListAsync(); + + public Task> GetSummaries(string searchString) => + _dbContext.MediaCollectionSummaries.FromSqlRaw( + @"SELECT mc.Id, mc.Name, Count(mismc.ItemsId) AS ItemCount, true AS IsSimple + FROM MediaCollections mc + INNER JOIN SimpleMediaCollections smc ON smc.Id = mc.Id + LEFT OUTER JOIN MediaItemSimpleMediaCollection mismc ON mismc.SimpleMediaCollectionsId = mc.Id + WHERE mc.Name LIKE {0} + GROUP BY mc.Id, mc.Name + UNION ALL + SELECT mc.Id, mc.Name, Count(mi.Id) AS ItemCount, false AS IsSimple + FROM MediaCollections mc + INNER JOIN TelevisionMediaCollections tmc ON tmc.Id = mc.Id + LEFT OUTER JOIN MediaItems mi ON (tmc.SeasonNumber IS NULL OR mi.Metadata_SeasonNumber = tmc.SeasonNumber) + AND mi.Metadata_Title = tmc.ShowTitle + WHERE mc.Name LIKE {0} + GROUP BY mc.Id, mc.Name", + $"%{searchString}%").ToListAsync(); + + public Task>> GetItems(int id) => + Get(id).MapT( + collection => collection switch + { + SimpleMediaCollection s => SimpleItems(s), + TelevisionMediaCollection t => TelevisionItems(t) + }).Bind(x => x.Sequence()); + + public Task>> GetSimpleMediaCollectionItems(int id) => + GetSimpleMediaCollection(id).MapT(SimpleItems).Bind(x => x.Sequence()); + + public Task>> GetTelevisionMediaCollectionItems(int id) => + GetTelevisionMediaCollection(id).MapT(TelevisionItems).Bind(x => x.Sequence()); + + public Task Update(SimpleMediaCollection collection) + { + _dbContext.SimpleMediaCollections.Update(collection); + return _dbContext.SaveChangesAsync(); + } + + public async Task InsertOrIgnore(TelevisionMediaCollection collection) + { + if (!_dbContext.TelevisionMediaCollections.Any( + existing => existing.ShowTitle == collection.ShowTitle && + existing.SeasonNumber == collection.SeasonNumber)) + { + await _dbContext.TelevisionMediaCollections.AddAsync(collection); + await _dbContext.SaveChangesAsync(); + } + } + + public Task ReplaceItems(int collectionId, List mediaItems) => + GetSimpleMediaCollection(collectionId).IfSomeAsync( + async c => + { + await SimpleItems(c); + + c.Items.Clear(); + foreach (MediaItem mediaItem in mediaItems) + { + c.Items.Add(mediaItem); + } + + _dbContext.SimpleMediaCollections.Update(c); + await _dbContext.SaveChangesAsync(); + }); + + public async Task Delete(int mediaCollectionId) + { + MediaCollection mediaCollection = await _dbContext.MediaCollections.FindAsync(mediaCollectionId); + _dbContext.MediaCollections.Remove(mediaCollection); + await _dbContext.SaveChangesAsync(); + } + + public async Task DeleteEmptyTelevisionCollections() + { + List ids = await _dbContext.GenericIntegerIds.FromSqlRaw( + @"SELECT mc.Id FROM MediaCollections mc +INNER JOIN TelevisionMediaCollections t on mc.Id = t.Id +WHERE NOT EXISTS +(SELECT 1 FROM MediaItems mi WHERE t.ShowTitle = mi.Metadata_Title AND (t.SeasonNumber IS NULL OR t.SeasonNumber = mi.Metadata_SeasonNumber))") + .Map(i => i.Id) + .ToListAsync(); + + List toDelete = + await _dbContext.MediaCollections.Where(mc => ids.Contains(mc.Id)).ToListAsync(); + _dbContext.MediaCollections.RemoveRange(toDelete); + + await _dbContext.SaveChangesAsync(); + } + + private async Task> SimpleItems(SimpleMediaCollection collection) + { + await _dbContext.Entry(collection).Collection(c => c.Items).LoadAsync(); + return collection.Items.ToList(); + } + + private Task> TelevisionItems(TelevisionMediaCollection collection) => + _dbContext.MediaItems + .Filter(c => c.Metadata.Title == collection.ShowTitle) + .Filter(c => collection.SeasonNumber == null || c.Metadata.SeasonNumber == collection.SeasonNumber) + .ToListAsync(); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs new file mode 100644 index 000000000..92c85f6ba --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class MediaItemRepository : IMediaItemRepository + { + private readonly TvContext _dbContext; + + public MediaItemRepository(TvContext dbContext) => _dbContext = dbContext; + + public async Task Add(MediaItem mediaItem) + { + await _dbContext.MediaItems.AddAsync(mediaItem); + await _dbContext.SaveChangesAsync(); + return mediaItem.Id; + } + + public Task> Get(int id) => + _dbContext.MediaItems.SingleOrDefaultAsync(i => i.Id == id).Map(Optional); + + public Task> GetAll() => _dbContext.MediaItems.ToListAsync(); + + public Task> GetAll(MediaType mediaType) => + _dbContext.MediaItems + .Include(i => i.Source) + .Filter(i => i.Metadata.MediaType == mediaType) + .ToListAsync(); + + public Task> GetAllByMediaSourceId(int mediaSourceId) => + _dbContext.MediaItems + .Filter(i => i.MediaSourceId == mediaSourceId) + .ToListAsync(); + + public async Task Update(MediaItem mediaItem) + { + _dbContext.MediaItems.Update(mediaItem); + await _dbContext.SaveChangesAsync(); + } + + public async Task Delete(int mediaItemId) + { + MediaItem mediaItem = await _dbContext.MediaItems.FindAsync(mediaItemId); + _dbContext.MediaItems.Remove(mediaItem); + await _dbContext.SaveChangesAsync(); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs new file mode 100644 index 000000000..3e1fab024 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class MediaSourceRepository : IMediaSourceRepository + { + private readonly TvContext _dbContext; + + public MediaSourceRepository(TvContext dbContext) => _dbContext = dbContext; + + public async Task Add(LocalMediaSource localMediaSource) + { + await _dbContext.LocalMediaSources.AddAsync(localMediaSource); + await _dbContext.SaveChangesAsync(); + return localMediaSource; + } + + public async Task Add(PlexMediaSource plexMediaSource) + { + await _dbContext.PlexMediaSources.AddAsync(plexMediaSource); + await _dbContext.SaveChangesAsync(); + return plexMediaSource; + } + + public async Task> GetAll() + { + List all = await _dbContext.MediaSources.ToListAsync(); + foreach (PlexMediaSource plex in all.OfType()) + { + await _dbContext.Entry(plex).Collection(p => p.Connections).LoadAsync(); + } + + return all; + } + + public Task> GetAllPlex() => + _dbContext.PlexMediaSources + .Include(p => p.Connections) + .ToListAsync(); + + public Task> Get(int id) => + _dbContext.MediaSources + .SingleOrDefaultAsync(s => s.Id == id) + .Map(Optional); + + public Task> GetPlex(int id) => + _dbContext.PlexMediaSources + .Include(p => p.Connections) + .Include(p => p.Libraries) + .SingleOrDefaultAsync(p => p.Id == id) + .Map(Optional); + + public Task CountMediaItems(int id) => + _dbContext.MediaItems.CountAsync(i => i.MediaSourceId == id); + + public async Task Update(PlexMediaSource plexMediaSource) + { + _dbContext.PlexMediaSources.Update(plexMediaSource); + await _dbContext.SaveChangesAsync(); + } + + public async Task Delete(int id) + { + MediaSource mediaSource = await _dbContext.MediaSources.FindAsync(id); + _dbContext.MediaSources.Remove(mediaSource); + await _dbContext.SaveChangesAsync(); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs new file mode 100644 index 000000000..5b24b0b8d --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class PlayoutRepository : IPlayoutRepository + { + private readonly TvContext _dbContext; + + public PlayoutRepository(TvContext dbContext) => _dbContext = dbContext; + + public async Task Add(Playout playout) + { + await _dbContext.Playouts.AddAsync(playout); + await _dbContext.SaveChangesAsync(); + return playout; + } + + public Task> Get(int id) => + _dbContext.Playouts.SingleOrDefaultAsync(p => p.Id == id).Map(Optional); + + public async Task> GetFull(int id) => + await _dbContext.Playouts + .Include(p => p.Channel) + .Include(p => p.Items) + .Include(p => p.ProgramScheduleAnchors) + .Include(p => p.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.MediaCollection) + .OrderBy(p => p.Id) // https://github.com/dotnet/efcore/issues/22579#issuecomment-694772289 + .SingleOrDefaultAsync(p => p.Id == id); + + public async Task> GetPlayoutItem(int channelId, DateTimeOffset now) + { + var p1 = new SqliteParameter("channelId", channelId); + var p2 = new SqliteParameter("now", now); + return await _dbContext.PlayoutItems + .FromSqlRaw( + "select i.* from playoutitems i inner join playouts p on i.playoutid = p.id where p.channelid = @channelId and i.start <= @now and i.finish > @now", + p1, + p2) + .Include(i => i.MediaItem) + .AsNoTracking() + .SingleOrDefaultAsync(); + } + + public Task> GetPlayoutItems(int playoutId) => + _dbContext.PlayoutItems + .Include(i => i.MediaItem) + .ThenInclude(mi => mi.Metadata) + .Filter(i => i.PlayoutId == playoutId) + .ToListAsync(); + + public Task> GetPlayoutIdsForMediaItems(Seq mediaItems) + { + var ids = string.Join(", ", mediaItems.Map(mi => mi.Id)); + return _dbContext.Playouts.FromSqlRaw( + @"SELECT DISTINCT p.* FROM Playouts p +INNER JOIN ProgramScheduleItems psi on psi.ProgramScheduleId = p.ProgramScheduleId +INNER JOIN SimpleMediaCollections smc on smc.Id = psi.MediaCollectionId +INNER JOIN MediaItemSimpleMediaCollection mismc on mismc.SimpleMediaCollectionsId = smc.Id +WHERE mismc.ItemsId in ({0}) +UNION +SELECT DISTINCT p.* FROM Playouts p +INNER JOIN ProgramScheduleItems psi on psi.ProgramScheduleId = p.ProgramScheduleId +INNER JOIN TelevisionMediaCollections tmc on tmc.Id = psi.MediaCollectionId +INNER JOIN MediaItems mi on mi.Metadata_Title = tmc.ShowTitle and (tmc.SeasonNumber is null or tmc.SeasonNumber = mi.Metadata_SeasonNumber) +WHERE mi.Id in ({0})", + ids).Select(p => p.Id).ToListAsync(); + } + + public Task> GetAll() => + _dbContext.Playouts + .Include(p => p.Channel) + .Include(p => p.ProgramSchedule) + .ToListAsync(); + + public async Task Update(Playout playout) + { + _dbContext.Playouts.Update(playout); + await _dbContext.SaveChangesAsync(); + } + + public async Task Delete(int playoutId) + { + Playout playout = await _dbContext.Playouts.FindAsync(playoutId); + _dbContext.Playouts.Remove(playout); + await _dbContext.SaveChangesAsync(); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ProgramScheduleRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ProgramScheduleRepository.cs new file mode 100644 index 000000000..7cb8150e5 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/ProgramScheduleRepository.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class ProgramScheduleRepository : IProgramScheduleRepository + { + private readonly TvContext _dbContext; + + public ProgramScheduleRepository(TvContext dbContext) => _dbContext = dbContext; + + public async Task Add(ProgramSchedule programSchedule) + { + await _dbContext.ProgramSchedules.AddAsync(programSchedule); + await _dbContext.SaveChangesAsync(); + return programSchedule; + } + + public Task> Get(int id) => + _dbContext.ProgramSchedules.SingleOrDefaultAsync(s => s.Id == id).Map(Optional); + + public async Task> GetWithPlayouts(int id) => + await _dbContext.ProgramSchedules + .Include(ps => ps.Items) + .Include(ps => ps.Playouts) + .SingleOrDefaultAsync(ps => ps.Id == id); + + public Task> GetAll() => + _dbContext.ProgramSchedules.ToListAsync(); + + public async Task Update(ProgramSchedule programSchedule) + { + _dbContext.ProgramSchedules.Update(programSchedule); + await _dbContext.SaveChangesAsync(); + await _dbContext.Entry(programSchedule).Collection(s => s.Items).Query().Include(i => i.MediaCollection) + .LoadAsync(); + await _dbContext.Entry(programSchedule).Collection(s => s.Playouts).LoadAsync(); + } + + public async Task Delete(int programScheduleId) + { + ProgramSchedule programSchedule = await _dbContext.ProgramSchedules.FindAsync(programScheduleId); + _dbContext.ProgramSchedules.Remove(programSchedule); + await _dbContext.SaveChangesAsync(); + } + + public async Task>> GetItems(int programScheduleId) + { + Option maybeSchedule = await Get(programScheduleId); + return await maybeSchedule.Map( + async programSchedule => + { + await _dbContext.Entry(programSchedule).Collection(s => s.Items).LoadAsync(); + await _dbContext.Entry(programSchedule).Collection(s => s.Items).Query() + .Include(i => i.MediaCollection).LoadAsync(); + return programSchedule.Items; + }).Sequence(); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ResolutionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ResolutionRepository.cs new file mode 100644 index 000000000..0bdcf2fc0 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/ResolutionRepository.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class ResolutionRepository : IResolutionRepository + { + private readonly TvContext _dbContext; + + public ResolutionRepository(TvContext dbContext) => _dbContext = dbContext; + + public Task> Get(int id) => + _dbContext.Resolutions.SingleOrDefaultAsync(r => r.Id == id).Map(Optional); + + public Task> GetAll() => + _dbContext.Resolutions.ToListAsync(); + } +} diff --git a/ErsatzTV.Infrastructure/Data/TvContext.cs b/ErsatzTV.Infrastructure/Data/TvContext.cs new file mode 100644 index 000000000..a8866e1f5 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/TvContext.cs @@ -0,0 +1,45 @@ +using ErsatzTV.Core.AggregateModels; +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Infrastructure.Data +{ + public class TvContext : DbContext + { + private readonly ILoggerFactory _loggerFactory; + + public TvContext(DbContextOptions options, ILoggerFactory loggerFactory) + : base(options) => + _loggerFactory = loggerFactory; + + public DbSet ConfigElements { get; set; } + public DbSet Channels { get; set; } + public DbSet MediaSources { get; set; } + public DbSet LocalMediaSources { get; set; } + public DbSet PlexMediaSources { get; set; } + public DbSet MediaItems { get; set; } + public DbSet MediaCollections { get; set; } + public DbSet SimpleMediaCollections { get; set; } + public DbSet TelevisionMediaCollections { get; set; } + public DbSet ProgramSchedules { get; set; } + public DbSet Playouts { get; set; } + public DbSet PlayoutItems { get; set; } + public DbSet PlayoutProgramScheduleItemAnchors { get; set; } + public DbSet FFmpegProfiles { get; set; } + public DbSet Resolutions { get; set; } + + // support raw sql queries + public DbSet MediaCollectionSummaries { get; set; } + public DbSet GenericIntegerIds { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => + optionsBuilder.UseLoggerFactory(_loggerFactory); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.ApplyConfigurationsFromAssembly(typeof(TvContext).Assembly); + } + } +} diff --git a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj new file mode 100644 index 000000000..b1afe912c --- /dev/null +++ b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj @@ -0,0 +1,17 @@ + + + + net5.0 + + + + + + + + + + + + + diff --git a/ErsatzTV.Infrastructure/HttpLoggingHandler.cs b/ErsatzTV.Infrastructure/HttpLoggingHandler.cs new file mode 100644 index 000000000..5b0307faa --- /dev/null +++ b/ErsatzTV.Infrastructure/HttpLoggingHandler.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace ErsatzTV +{ + namespace wms_xamarin + { + public class HttpLoggingHandler : DelegatingHandler + { + private readonly string[] types = { "html", "text", "xml", "json", "txt", "x-www-form-urlencoded" }; + + public HttpLoggingHandler(HttpMessageHandler innerHandler = null) : base( + innerHandler ?? new HttpClientHandler()) + { + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + await Task.Delay(1, cancellationToken).ConfigureAwait(false); + DateTime start = DateTime.Now; + HttpRequestMessage req = request; + var msg = $"[{req.RequestUri.PathAndQuery} - Request]"; + + Debug.WriteLine($"{msg}========Request Start=========="); + Debug.WriteLine( + $"{msg} {req.Method} {req.RequestUri.PathAndQuery} {req.RequestUri.Scheme}/{req.Version}"); + Debug.WriteLine($"{msg} Host: {req.RequestUri.Scheme}://{req.RequestUri.Host}"); + + foreach (KeyValuePair> header in req.Headers) + { + Debug.WriteLine($"{msg} {header.Key}: {string.Join(", ", header.Value)}"); + } + + if (req.Content != null) + { + foreach (KeyValuePair> header in req.Content.Headers) + { + Debug.WriteLine($"{msg} {header.Key}: {string.Join(", ", header.Value)}"); + } + + Debug.WriteLine($"{msg} Content:"); + + if (req.Content is StringContent || IsTextBasedContentType(req.Headers) || + IsTextBasedContentType(req.Content.Headers)) + { + string result = await req.Content.ReadAsStringAsync(); + + Debug.WriteLine($"{msg} {string.Join("", result.Take(256))}..."); + } + } + + HttpResponseMessage response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + Debug.WriteLine($"{msg}==========Request End=========="); + + msg = $"[{req.RequestUri.PathAndQuery} - Response]"; + + Debug.WriteLine($"{msg}=========Response Start========="); + + HttpResponseMessage resp = response; + + Debug.WriteLine( + $"{msg} {req.RequestUri.Scheme.ToUpper()}/{resp.Version} {(int) resp.StatusCode} {resp.ReasonPhrase}"); + + foreach (KeyValuePair> header in resp.Headers) + { + Debug.WriteLine($"{msg} {header.Key}: {string.Join(", ", header.Value)}"); + } + + if (resp.Content != null) + { + foreach (KeyValuePair> header in resp.Content.Headers) + { + Debug.WriteLine($"{msg} {header.Key}: {string.Join(", ", header.Value)}"); + } + + Debug.WriteLine($"{msg} Content:"); + + if (resp.Content is StringContent || IsTextBasedContentType(resp.Headers) || + IsTextBasedContentType(resp.Content.Headers)) + { + string result = await resp.Content.ReadAsStringAsync(); + + Debug.WriteLine($"{msg} {string.Join("", result.Take(256))}..."); + } + } + + Debug.WriteLine($"{msg} Duration: {DateTime.Now - start}"); + Debug.WriteLine($"{msg}==========Response End=========="); + return response; + } + + private bool IsTextBasedContentType(HttpHeaders headers) + { + IEnumerable values; + if (!headers.TryGetValues("Content-Type", out values)) + { + return false; + } + + string header = string.Join(" ", values).ToLowerInvariant(); + + return types.Any(t => header.Contains(t)); + } + } + } +} diff --git a/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs b/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs new file mode 100644 index 000000000..edd2d1078 --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using ErsatzTV.Infrastructure.Plex.Models; +using Refit; + +namespace ErsatzTV.Infrastructure.Plex +{ + [Headers("Accept: application/json")] + public interface IPlexServerApi + { + [Get("/library/sections")] + public Task> GetLibraries( + [Query] [AliasAs("X-Plex-Token")] + string token); + } +} diff --git a/ErsatzTV.Infrastructure/Plex/IPlexTvApi.cs b/ErsatzTV.Infrastructure/Plex/IPlexTvApi.cs new file mode 100644 index 000000000..5669041f8 --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/IPlexTvApi.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Infrastructure.Plex.Models; +using Refit; + +namespace ErsatzTV.Infrastructure.Plex +{ + [Headers("Accept: application/json")] + public interface IPlexTvApi + { + [Post("/pins")] + Task StartPinFlow( + [Query] [AliasAs("X-Plex-Product")] + string product, + [Query] [AliasAs("X-Plex-Client-Identifier")] + string clientIdentifier, + [Query] + bool strong = true); + + [Get("/pins/{id}")] + Task GetPinStatus( + int id, + [Query] + string code, + [Query] [AliasAs("X-Plex-Client-Identifier")] + string clientIdentifier); + + [Get("/user")] + Task GetUser( + [Query] [AliasAs("X-Plex-Product")] + string product, + [Query] [AliasAs("X-Plex-Client-Identifier")] + string clientIdentifier, + [Query] [AliasAs("X-Plex-Token")] + string token); + + [Get("/resources")] + Task> GetResources( + [Header("X-Plex-Client-Identifier")] + string clientIdentifier, + [Header("X-Plex-Token")] + string token); + } +} diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexLibraryResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexLibraryResponse.cs new file mode 100644 index 000000000..ada996f88 --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexLibraryResponse.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.Infrastructure.Plex.Models +{ + public class PlexLibraryResponse + { + public string Key { get; set; } + public string Title { get; set; } + public string Type { get; set; } + public int Hidden { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexMediaContainerResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexMediaContainerResponse.cs new file mode 100644 index 000000000..37af14d68 --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexMediaContainerResponse.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Infrastructure.Plex.Models +{ + public class PlexMediaContainerResponse + { + public PlexMediaContainerContent MediaContainer { get; set; } + } + + public class PlexMediaContainerContent + { + public List Directory { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexPinResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexPinResponse.cs new file mode 100644 index 000000000..3f70cd55c --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexPinResponse.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Infrastructure.Plex.Models +{ + public class PlexPinResponse + { + public int Id { get; set; } + public string Code { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexResource.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexResource.cs new file mode 100644 index 000000000..afe65750f --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexResource.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Infrastructure.Plex.Models +{ + public class PlexResource + { + public string Name { get; set; } + public string ProductVersion { get; set; } + public string ClientIdentifier { get; set; } + + public string AccessToken { get; set; } + + public bool Owned { get; set; } + public string Provides { get; set; } + public List Connections { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexResourceConnection.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexResourceConnection.cs new file mode 100644 index 000000000..4d494093b --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexResourceConnection.cs @@ -0,0 +1,11 @@ +namespace ErsatzTV.Infrastructure.Plex.Models +{ + public class PlexResourceConnection + { + public string Protocol { get; set; } + public string Address { get; set; } + public string Port { get; set; } + public string Uri { get; set; } + public string Local { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexTokenResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexTokenResponse.cs new file mode 100644 index 000000000..91d87bd61 --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexTokenResponse.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Infrastructure.Plex.Models +{ + public class PlexTokenResponse + { + public string AuthToken { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexUserResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexUserResponse.cs new file mode 100644 index 000000000..b580ac054 --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexUserResponse.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Infrastructure.Plex.Models +{ + public class PlexUserResponse + { + public string Email { get; set; } + public string AuthToken { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Plex/PlexSecretStore.cs b/ErsatzTV.Infrastructure/Plex/PlexSecretStore.cs new file mode 100644 index 000000000..31844a29f --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/PlexSecretStore.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Plex; +using LanguageExt; +using Newtonsoft.Json; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Plex +{ + public class PlexSecretStore : IPlexSecretStore + { + public Task GetClientIdentifier() => + ReadSecrets().Bind( + plexSecrets => Optional(plexSecrets.ClientIdentifier).Match( + Task.FromResult, + async () => + { + string identifier = GenerateClientIdentifier(); + plexSecrets.ClientIdentifier = identifier; + await SaveSecrets(plexSecrets); + return identifier; + })); + + public Task> GetUserAuthTokens() => + ReadSecrets().Map( + s => Optional(s.UserAuthTokens).Match( + tokens => tokens.Map(kvp => new PlexUserAuthToken(kvp.Key, kvp.Value)).ToList(), + () => new List())); + + public Task> GetServerAuthTokens() => + ReadSecrets().Map( + s => Optional(s.ServerAuthTokens).Match( + tokens => tokens.Map(kvp => new PlexServerAuthToken(kvp.Key, kvp.Value)).ToList(), + () => new List())); + + public Task> GetServerAuthToken(string clientIdentifier) => + ReadSecrets().Map( + s => Optional(s.ServerAuthTokens.SingleOrDefault(kvp => kvp.Key == clientIdentifier)) + .Map(kvp => new PlexServerAuthToken(kvp.Key, kvp.Value))); + + public Task UpsertUserAuthToken(PlexUserAuthToken userAuthToken) => + ReadSecrets().Bind( + secrets => + { + secrets.UserAuthTokens ??= new Dictionary(); + secrets.UserAuthTokens[userAuthToken.Email] = userAuthToken.AuthToken; + return SaveSecrets(secrets); + }); + + public Task UpsertServerAuthToken(PlexServerAuthToken serverAuthToken) => + ReadSecrets().Bind( + secrets => + { + secrets.ServerAuthTokens ??= new Dictionary(); + secrets.ServerAuthTokens[serverAuthToken.ClientIdentifier] = serverAuthToken.AuthToken; + return SaveSecrets(secrets); + }); + + private static Task ReadSecrets() => + File.ReadAllTextAsync(FileSystemLayout.PlexSecretsPath) + .Map(JsonConvert.DeserializeObject) + .Map(s => Optional(s).IfNone(new PlexSecrets())); + + private static Task SaveSecrets(PlexSecrets plexSecrets) => + Some(JsonConvert.SerializeObject(plexSecrets)).Match( + s => File.WriteAllTextAsync(FileSystemLayout.PlexSecretsPath, s).ToUnit(), + Task.FromResult(Unit.Default)); + + private static string GenerateClientIdentifier() => + Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + .TrimEnd('=') + .Replace("/", "_") + .Replace("+", "-"); + } +} diff --git a/ErsatzTV.Infrastructure/Plex/PlexSecrets.cs b/ErsatzTV.Infrastructure/Plex/PlexSecrets.cs new file mode 100644 index 000000000..124f08613 --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/PlexSecrets.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Infrastructure.Plex +{ + public class PlexSecrets + { + public string ClientIdentifier { get; set; } + public Dictionary UserAuthTokens { get; set; } + public Dictionary ServerAuthTokens { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs new file mode 100644 index 000000000..90dc32008 --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Plex; +using ErsatzTV.Infrastructure.Plex.Models; +using LanguageExt; +using Refit; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Plex +{ + public class PlexServerApiClient : IPlexServerApiClient + { + public async Task>> GetLibraries( + PlexMediaSourceConnection connection, + PlexServerAuthToken token) + { + try + { + IPlexServerApi service = RestService.For(connection.Uri); + List directory = + await service.GetLibraries(token.AuthToken).Map(r => r.MediaContainer.Directory); + return directory + .Filter(l => l.Hidden == 0) + .Map(Project) + .Somes() + .ToList(); + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + private static Option Project(PlexLibraryResponse response) => + response.Type switch + { + "show" => new PlexMediaSourceLibrary + { + Key = response.Key, + Name = response.Title, + MediaType = MediaType.TvShow + }, + "movie" => new PlexMediaSourceLibrary + { + Key = response.Key, + Name = response.Title, + MediaType = MediaType.Movie + }, + // TODO: "artist" for music libraries + _ => None + }; + } +} diff --git a/ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs new file mode 100644 index 000000000..9c3f45fd3 --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Plex; +using ErsatzTV.Infrastructure.Plex.Models; +using LanguageExt; + +namespace ErsatzTV.Infrastructure.Plex +{ + public class PlexTvApiClient : IPlexTvApiClient + { + private const string AppName = "ErsatzTV"; + private readonly IPlexSecretStore _plexSecretStore; + + private readonly IPlexTvApi _plexTvApi; + + public PlexTvApiClient(IPlexTvApi plexTvApi, IPlexSecretStore plexSecretStore) + { + // var client = new HttpClient(new HttpLoggingHandler()) { BaseAddress = new Uri("https://plex.tv/api/v2") }; + + _plexTvApi = plexTvApi; // RestService.For(client); + _plexSecretStore = plexSecretStore; + } + + public async Task>> GetServers() + { + try + { + var result = new List(); + string clientIdentifier = await _plexSecretStore.GetClientIdentifier(); + foreach (PlexUserAuthToken token in await _plexSecretStore.GetUserAuthTokens()) + { + List resources = await _plexTvApi.GetResources(clientIdentifier, token.AuthToken); + IEnumerable sources = resources + .Filter(r => r.Provides.Split(",").Any(p => p == "server")) + .Filter(r => r.Owned) // TODO: maybe support non-owned servers in the future + .Map( + resource => + { + var serverAuthToken = new PlexServerAuthToken( + resource.ClientIdentifier, + resource.AccessToken); + + _plexSecretStore.UpsertServerAuthToken(serverAuthToken); + + var source = new PlexMediaSource + { + Name = resource.Name, + ProductVersion = resource.ProductVersion, + ClientIdentifier = resource.ClientIdentifier, + Connections = resource.Connections + .Map(c => new PlexMediaSourceConnection { Uri = c.Uri }).ToList() + }; + + return source; + }); + result.AddRange(sources); + } + + return result; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + public async Task> StartPinFlow() + { + try + { + string clientIdentifier = await _plexSecretStore.GetClientIdentifier(); + PlexPinResponse pinResponse = await _plexTvApi.StartPinFlow(AppName, clientIdentifier); + return new PlexAuthPin(pinResponse.Id, pinResponse.Code, clientIdentifier); + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + public async Task TryCompletePinFlow(PlexAuthPin authPin) + { + try + { + PlexTokenResponse response = await _plexTvApi.GetPinStatus( + authPin.Id, + authPin.Code, + authPin.ClientIdentifier); + + if (!string.IsNullOrWhiteSpace(response.AuthToken)) + { + PlexUserResponse user = await _plexTvApi.GetUser( + AppName, + authPin.ClientIdentifier, + response.AuthToken); + + var token = new PlexUserAuthToken(user.Email, user.AuthToken); + await _plexSecretStore.UpsertUserAuthToken(token); + + return true; + } + } + catch (Exception ex) + { + // ignored + } + + return false; + } + } +} diff --git a/ErsatzTV.sln b/ErsatzTV.sln new file mode 100644 index 000000000..05ba42079 --- /dev/null +++ b/ErsatzTV.sln @@ -0,0 +1,54 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV", "ErsatzTV\ErsatzTV.csproj", "{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.CommandLine", "ErsatzTV.CommandLine\ErsatzTV.CommandLine.csproj", "{B3615FB4-9A0A-419F-AB49-CD162B14AC9A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Api.Sdk", "generated\ErsatzTV.Api.Sdk\src\ErsatzTV.Api.Sdk\ErsatzTV.Api.Sdk.csproj", "{2926C44D-6351-4E57-976D-E7A5919CC406}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Infrastructure", "ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj", "{C56FC23D-B863-401E-8E7C-E92BC307AFC1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Core", "ErsatzTV.Core\ErsatzTV.Core.csproj", "{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Application", "ErsatzTV.Application\ErsatzTV.Application.csproj", "{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Core.Tests", "ErsatzTV.Core.Tests\ErsatzTV.Core.Tests.csproj", "{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|Any CPU.Build.0 = Release|Any CPU + {B3615FB4-9A0A-419F-AB49-CD162B14AC9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3615FB4-9A0A-419F-AB49-CD162B14AC9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3615FB4-9A0A-419F-AB49-CD162B14AC9A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3615FB4-9A0A-419F-AB49-CD162B14AC9A}.Release|Any CPU.Build.0 = Release|Any CPU + {2926C44D-6351-4E57-976D-E7A5919CC406}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2926C44D-6351-4E57-976D-E7A5919CC406}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2926C44D-6351-4E57-976D-E7A5919CC406}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2926C44D-6351-4E57-976D-E7A5919CC406}.Release|Any CPU.Build.0 = Release|Any CPU + {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|Any CPU.Build.0 = Release|Any CPU + {BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Release|Any CPU.Build.0 = Release|Any CPU + {CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Release|Any CPU.Build.0 = Release|Any CPU + {CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + EndGlobalSection +EndGlobal diff --git a/ErsatzTV.sln.DotSettings b/ErsatzTV.sln.DotSettings new file mode 100644 index 000000000..40c458df3 --- /dev/null +++ b/ErsatzTV.sln.DotSettings @@ -0,0 +1,25 @@ + + DTO + HDHR + SAR + True + True + True + True + True + True + True + True + True + True + True + True + True + True + + True + True + True + True + True + True \ No newline at end of file diff --git a/ErsatzTV/App.razor b/ErsatzTV/App.razor new file mode 100644 index 000000000..552da3a94 --- /dev/null +++ b/ErsatzTV/App.razor @@ -0,0 +1,10 @@ + + + + + + +

Sorry, there's nothing at this address.

+
+
+
\ No newline at end of file diff --git a/ErsatzTV/Controllers/Api/ChannelsController.cs b/ErsatzTV/Controllers/Api/ChannelsController.cs new file mode 100644 index 000000000..4b6e76c0e --- /dev/null +++ b/ErsatzTV/Controllers/Api/ChannelsController.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using ErsatzTV.Application.Channels; +using ErsatzTV.Application.Channels.Commands; +using ErsatzTV.Application.Channels.Queries; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api +{ + [ApiController] + [Route("api/channels")] + [Produces("application/json")] + public class ChannelsController : ControllerBase + { + private readonly IMediator _mediator; + + public ChannelsController(IMediator mediator) => _mediator = mediator; + + [HttpPost] + [ProducesResponseType(typeof(ChannelViewModel), 200)] + [ProducesResponseType(400)] + public Task Add( + [Required] [FromBody] + CreateChannel createChannel) => + _mediator.Send(createChannel).ToActionResult(); + + [HttpGet("{channelId}")] + [ProducesResponseType(typeof(ChannelViewModel), 200)] + [ProducesResponseType(404)] + public Task Get(int channelId) => + _mediator.Send(new GetChannelById(channelId)).ToActionResult(); + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), 200)] + public Task GetAll() => + _mediator.Send(new GetAllChannels()).ToActionResult(); + + [HttpPatch] + [ProducesResponseType(typeof(ChannelViewModel), 200)] + [ProducesResponseType(400)] + public Task Update( + [Required] [FromBody] + UpdateChannel updateChannel) => + _mediator.Send(updateChannel).ToActionResult(); + + [HttpDelete] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public Task Delete( + [Required] [FromBody] + DeleteChannel deleteChannel) => + _mediator.Send(deleteChannel).ToActionResult(); + } +} diff --git a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs new file mode 100644 index 000000000..3e08925be --- /dev/null +++ b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Application.FFmpegProfiles.Commands; +using ErsatzTV.Application.FFmpegProfiles.Queries; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api +{ + [ApiController] + [Route("api/ffmpeg/profiles")] + [Produces("application/json")] + public class FFmpegProfileController : ControllerBase + { + private readonly IMediator _mediator; + + public FFmpegProfileController(IMediator mediator) => _mediator = mediator; + + [HttpPost] + [ProducesResponseType(typeof(FFmpegProfileViewModel), 200)] + [ProducesResponseType(400)] + public Task Add( + [Required] [FromBody] + CreateFFmpegProfile createFFmpegProfile) => + _mediator.Send(createFFmpegProfile).ToActionResult(); + + [HttpGet("{ffmpegProfileId}")] + [ProducesResponseType(typeof(FFmpegProfileViewModel), 200)] + [ProducesResponseType(404)] + public Task Get(int ffmpegProfileId) => + _mediator.Send(new GetFFmpegProfileById(ffmpegProfileId)).ToActionResult(); + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), 200)] + public Task GetAll() => + _mediator.Send(new GetAllFFmpegProfiles()).ToActionResult(); + + [HttpPatch] + [ProducesResponseType(typeof(FFmpegProfileViewModel), 200)] + [ProducesResponseType(400)] + public Task Update( + [Required] [FromBody] + UpdateFFmpegProfile updateFFmpegProfile) => + _mediator.Send(updateFFmpegProfile).ToActionResult(); + + [HttpDelete] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public Task Delete( + [Required] [FromBody] + DeleteFFmpegProfile deleteFFmpegProfile) => + _mediator.Send(deleteFFmpegProfile).ToActionResult(); + } +} diff --git a/ErsatzTV/Controllers/Api/MediaCollectionsController.cs b/ErsatzTV/Controllers/Api/MediaCollectionsController.cs new file mode 100644 index 000000000..dfda3dc4f --- /dev/null +++ b/ErsatzTV/Controllers/Api/MediaCollectionsController.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.MediaCollections.Commands; +using ErsatzTV.Application.MediaCollections.Queries; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api +{ + [ApiController] + [Route("api/media/collections")] + [Produces("application/json")] + public class MediaCollectionsController : ControllerBase + { + private readonly IMediator _mediator; + + public MediaCollectionsController(IMediator mediator) => _mediator = mediator; + + [HttpPost] + [ProducesResponseType(typeof(MediaCollectionViewModel), 200)] + [ProducesResponseType(400)] + public Task Add( + [Required] [FromBody] + CreateSimpleMediaCollection createCollection) => + _mediator.Send(createCollection).ToActionResult(); + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), 200)] + public Task GetAll() => + _mediator.Send(new GetAllMediaCollections()).ToActionResult(); + + [HttpGet("{id}")] + [ProducesResponseType(typeof(MediaCollectionViewModel), 200)] + [ProducesResponseType(404)] + public Task Get(int id) => + _mediator.Send(new GetSimpleMediaCollectionById(id)).ToActionResult(); + + [HttpGet("{id}/items")] + [ProducesResponseType(typeof(IEnumerable), 200)] + [ProducesResponseType(404)] + public Task GetItems(int id) => + _mediator.Send(new GetSimpleMediaCollectionItems(id)).ToActionResult(); + + [HttpPut("{id}/items")] + [ProducesResponseType(typeof(IEnumerable), 200)] + [ProducesResponseType(404)] + public Task PutItems( + int id, + [Required] [FromBody] + List mediaItemIds) => + _mediator.Send(new ReplaceSimpleMediaCollectionItems(id, mediaItemIds)).ToActionResult(); + } +} diff --git a/ErsatzTV/Controllers/Api/MediaItemsController.cs b/ErsatzTV/Controllers/Api/MediaItemsController.cs new file mode 100644 index 000000000..a0ae06206 --- /dev/null +++ b/ErsatzTV/Controllers/Api/MediaItemsController.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Application.MediaItems.Commands; +using ErsatzTV.Application.MediaItems.Queries; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api +{ + [ApiController] + [Route("api/media/items")] + [Produces("application/json")] + public class MediaItemsController : ControllerBase + { + private readonly IMediator _mediator; + + public MediaItemsController(IMediator mediator) => _mediator = mediator; + + [HttpPost] + [ProducesResponseType(typeof(MediaItemViewModel), 200)] + [ProducesResponseType(400)] + public Task Add( + [Required] [FromBody] + CreateMediaItem createMediaItem) => + _mediator.Send(createMediaItem).ToActionResult(); + + [HttpGet("{mediaItemId}")] + [ProducesResponseType(typeof(MediaItemViewModel), 200)] + [ProducesResponseType(404)] + public Task Get(int mediaItemId) => + _mediator.Send(new GetMediaItemById(mediaItemId)).ToActionResult(); + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), 200)] + public Task GetAll() => + _mediator.Send(new GetAllMediaItems()).ToActionResult(); + + [HttpDelete] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public Task Delete( + [Required] [FromBody] + DeleteMediaItem deleteMediaItem) => + _mediator.Send(deleteMediaItem).ToActionResult(); + } +} diff --git a/ErsatzTV/Controllers/Api/MediaSourcesController.cs b/ErsatzTV/Controllers/Api/MediaSourcesController.cs new file mode 100644 index 000000000..6b4beffdc --- /dev/null +++ b/ErsatzTV/Controllers/Api/MediaSourcesController.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Application.MediaSources; +using ErsatzTV.Application.MediaSources.Queries; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api +{ + [ApiController] + [Route("api/media/sources")] + [Produces("application/json")] + public class MediaSourcesController : ControllerBase + { + private readonly IMediator _mediator; + + public MediaSourcesController(IMediator mediator) => _mediator = mediator; + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), 200)] + public Task GetAll() => + _mediator.Send(new GetAllMediaSources()).ToActionResult(); + + [HttpGet("{id}")] + [ProducesResponseType(typeof(MediaSourceViewModel), 200)] + [ProducesResponseType(404)] + public Task Get(int id) => + _mediator.Send(new GetMediaSourceById(id)).ToActionResult(); + } +} diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs new file mode 100644 index 000000000..b0422b9af --- /dev/null +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Application.Playouts.Commands; +using ErsatzTV.Application.Playouts.Queries; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api +{ + [ApiController] + [Route("api/playouts")] + [Produces("application/json")] + public class PlayoutController : ControllerBase + { + private readonly IMediator _mediator; + + public PlayoutController(IMediator mediator) => _mediator = mediator; + + [HttpPost] + [ProducesResponseType(typeof(PlayoutViewModel), 200)] + [ProducesResponseType(400)] + public Task Add( + [Required] [FromBody] + CreatePlayout createPlayout) => + _mediator.Send(createPlayout).ToActionResult(); + + [HttpGet("{playoutId}")] + [ProducesResponseType(typeof(PlayoutViewModel), 200)] + [ProducesResponseType(404)] + public Task Get(int playoutId) => + _mediator.Send(new GetPlayoutById(playoutId)).ToActionResult(); + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), 200)] + public Task GetAll() => + _mediator.Send(new GetAllPlayouts()).ToActionResult(); + + [HttpPatch] + [ProducesResponseType(typeof(PlayoutViewModel), 200)] + [ProducesResponseType(400)] + public Task Update( + [Required] [FromBody] + UpdatePlayout updatePlayout) => + _mediator.Send(updatePlayout).ToActionResult(); + + [HttpDelete] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public Task Delete( + [Required] [FromBody] + DeletePlayout deletePlayout) => + _mediator.Send(deletePlayout).ToActionResult(); + } +} diff --git a/ErsatzTV/Controllers/Api/ProgramScheduleController.cs b/ErsatzTV/Controllers/Api/ProgramScheduleController.cs new file mode 100644 index 000000000..90fe2068a --- /dev/null +++ b/ErsatzTV/Controllers/Api/ProgramScheduleController.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using ErsatzTV.Application.ProgramSchedules; +using ErsatzTV.Application.ProgramSchedules.Commands; +using ErsatzTV.Application.ProgramSchedules.Queries; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api +{ + [ApiController] + [Route("api/schedules")] + [Produces("application/json")] + public class ProgramScheduleController : ControllerBase + { + private readonly IMediator _mediator; + + public ProgramScheduleController(IMediator mediator) => _mediator = mediator; + + [HttpPost] + [ProducesResponseType(typeof(ProgramScheduleViewModel), 200)] + [ProducesResponseType(400)] + public Task Add( + [Required] [FromBody] + CreateProgramSchedule createProgramSchedule) => + _mediator.Send(createProgramSchedule).ToActionResult(); + + [HttpGet("{programScheduleId}")] + [ProducesResponseType(typeof(ProgramScheduleViewModel), 200)] + [ProducesResponseType(404)] + public Task Get(int programScheduleId) => + _mediator.Send(new GetProgramScheduleById(programScheduleId)).ToActionResult(); + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), 200)] + public Task GetAll() => + _mediator.Send(new GetAllProgramSchedules()).ToActionResult(); + + [HttpPatch] + [ProducesResponseType(typeof(ProgramScheduleViewModel), 200)] + [ProducesResponseType(400)] + public Task Update( + [Required] [FromBody] + UpdateProgramSchedule updateProgramSchedule) => + _mediator.Send(updateProgramSchedule).ToActionResult(); + + [HttpDelete] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public Task Delete( + [Required] [FromBody] + DeleteProgramSchedule deleteProgramSchedule) => + _mediator.Send(deleteProgramSchedule).ToActionResult(); + + [HttpGet("{programScheduleId}/items")] + [ProducesResponseType(typeof(IEnumerable), 200)] + [ProducesResponseType(404)] + public Task GetItems(int programScheduleId) => + _mediator.Send(new GetProgramScheduleItems(programScheduleId)).ToActionResult(); + + [HttpPut("{programScheduleId}/items")] + [ProducesResponseType(typeof(IEnumerable), 200)] + [ProducesResponseType(404)] + public Task PutItems( + int programScheduleId, + [Required] [FromBody] + List items) => + _mediator.Send(new ReplaceProgramScheduleItems(programScheduleId, items)).ToActionResult(); + + [HttpDelete("{programScheduleId}/items")] + [ProducesResponseType(typeof(IEnumerable), 200)] + [ProducesResponseType(404)] + public Task DeleteItems(int programScheduleId) => + _mediator.Send(new ReplaceProgramScheduleItems(programScheduleId, new List())) + .ToActionResult(); + + [HttpPost("items/add")] + [ProducesResponseType(typeof(IEnumerable), 200)] + [ProducesResponseType(404)] + public Task AddItem( + [Required] [FromBody] + AddProgramScheduleItem addProgramScheduleItem) => + _mediator.Send(addProgramScheduleItem).ToActionResult(); + } +} diff --git a/ErsatzTV/Controllers/HdhrController.cs b/ErsatzTV/Controllers/HdhrController.cs new file mode 100644 index 000000000..e294788f0 --- /dev/null +++ b/ErsatzTV/Controllers/HdhrController.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using ErsatzTV.Application.Channels.Queries; +using ErsatzTV.Core.Hdhr; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers +{ + [ApiController] + [ApiExplorerSettings(IgnoreApi = true)] + public class HdhrController : ControllerBase + { + private readonly IMediator _mediator; + + public HdhrController(IMediator mediator) => _mediator = mediator; + + [HttpGet("device.xml")] + public IActionResult DeviceXml() => + new OkObjectResult(new DeviceXml(Request.Scheme, Request.Host.ToString())); + + [HttpGet("discover.json")] + public IActionResult Discover() => + new OkObjectResult(new Discover(Request.Scheme, Request.Host.ToString(), 2)); + + [HttpGet("lineup_status.json")] + public IActionResult LineupStatus() => + new OkObjectResult(new LineupStatus()); + + [HttpGet("lineup.json")] + public Task Lineup() => + _mediator.Send(new GetChannelLineup(Request.Scheme, Request.Host.ToString())).ToActionResult(); + } +} diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs new file mode 100644 index 000000000..c5619672c --- /dev/null +++ b/ErsatzTV/Controllers/InternalController.cs @@ -0,0 +1,52 @@ +using System.Threading.Tasks; +using ErsatzTV.Application.Streaming.Queries; +using ErsatzTV.Extensions; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Controllers +{ + [ApiController] + [ApiExplorerSettings(IgnoreApi = true)] + public class InternalController : ControllerBase + { + private readonly ILogger _logger; + private readonly IMediator _mediator; + + public InternalController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + [HttpGet("ffmpeg/concat/{channelNumber}")] + public Task GetConcatPlaylist(int channelNumber) => + _mediator.Send(new GetConcatPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber)) + .ToActionResult(); + + [HttpGet("ffmpeg/stream/{channelNumber}")] + public Task GetStream(int channelNumber) => + _mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber)).Map( + result => + result.Match( + process => + { + _logger.LogDebug( + "ffmpeg arguments {FFmpegArguments}", + string.Join(" ", process.StartInfo.ArgumentList)); + process.Start(); + return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t"); + }, + error => + { + _logger.LogError( + "Failed to create stream for channel {ChannelNumber}: {Error}", + channelNumber, + error.Value); + return BadRequest(error.Value); + } + )); + } +} diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs new file mode 100644 index 000000000..cd2a00024 --- /dev/null +++ b/ErsatzTV/Controllers/IptvController.cs @@ -0,0 +1,72 @@ +using System.Threading.Tasks; +using ErsatzTV.Application.Channels.Queries; +using ErsatzTV.Application.Images; +using ErsatzTV.Application.Images.Queries; +using ErsatzTV.Application.Streaming.Queries; +using ErsatzTV.Core; +using ErsatzTV.Core.Iptv; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Controllers +{ + [ApiController] + [ApiExplorerSettings(IgnoreApi = true)] + public class IptvController : ControllerBase + { + private readonly ILogger _logger; + private readonly IMediator _mediator; + + public IptvController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + [HttpGet("iptv/channels.m3u")] + public Task GetChannelPlaylist() => + _mediator.Send(new GetChannelPlaylist(Request.Scheme, Request.Host.ToString())) + .Map(Ok); + + [HttpGet("iptv/xmltv.xml")] + public Task GetGuide() => + _mediator.Send(new GetChannelGuide(Request.Scheme, Request.Host.ToString())) + .Map(Ok); + + [HttpGet("iptv/channel/{channelNumber}.ts")] + public Task GetTransportStreamVideo(int channelNumber) => + _mediator.Send(new GetConcatProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber)) + .Map( + result => result.Match( + process => + { + _logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber); + process.Start(); + return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t"); + }, + error => BadRequest(error.Value))); + + [HttpGet("iptv/channel/{channelNumber}.m3u8")] + public Task GetHttpLiveStreamingVideo(int channelNumber) => + _mediator.Send(new GetHlsPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber)) + .Map( + result => result.Match( + playlist => + { + _logger.LogInformation("Starting hls stream for channel {ChannelNumber}", channelNumber); + return Content(playlist, "application/x-mpegurl"); + }, + error => BadRequest(error.Value))); + + [HttpGet("iptv/images/{fileName}")] + public async Task GetImage(string fileName) + { + Either imageContents = await _mediator.Send(new GetImageContents(fileName)); + return imageContents.Match( + Left: _ => new NotFoundResult(), + Right: r => new FileContentResult(r.Contents, r.MimeType)); + } + } +} diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj new file mode 100644 index 000000000..443202cb1 --- /dev/null +++ b/ErsatzTV/ErsatzTV.csproj @@ -0,0 +1,34 @@ + + + + net5.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ErsatzTV/Extensions/EitherToActionResult.cs b/ErsatzTV/Extensions/EitherToActionResult.cs new file mode 100644 index 000000000..dc77a3d87 --- /dev/null +++ b/ErsatzTV/Extensions/EitherToActionResult.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using LanguageExt; +using LanguageExt.Common; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Extensions +{ + public static class EitherToActionResult + { + public static Task ToActionResult(this Task> either) => either.Map(Match); + + public static Task ToActionResult(this Task> either) => either.Bind(Match); + + private static IActionResult Match(this Either either) => + either.Match( + Left: l => new BadRequestObjectResult(l), + Right: r => new OkObjectResult(r)); + + private static async Task Match(Either either) => + await either.MatchAsync( + async t => + { + await t; + return new OkResult(); + }, + e => new BadRequestObjectResult(e)); + } +} diff --git a/ErsatzTV/Extensions/HostExtensions.cs b/ErsatzTV/Extensions/HostExtensions.cs new file mode 100644 index 000000000..9dbcb6fca --- /dev/null +++ b/ErsatzTV/Extensions/HostExtensions.cs @@ -0,0 +1,45 @@ +using System; +using ErsatzTV.Infrastructure.Data; +using LanguageExt; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Extensions +{ + public static class HostExtensions + { + public static IHost SeedDatabase(this IHost host) + { + Unit _ = use(() => host.Services.CreateScope(), Seed); + return host; + } + + private static Unit Seed(IServiceScope scope) => + Try(() => scope.ServiceProvider) + .Bind(services => Try(GetDbContext(services))) + .Bind(ctx => Try(Migrate(ctx))) + .Bind(ctx => Try(InitializeDb(ctx))) + .IfFail(ex => LogException(ex, scope.ServiceProvider)); + + private static TvContext GetDbContext(IServiceProvider provider) => + provider.GetRequiredService(); + + private static TvContext Migrate(TvContext context) + { + context.Database.EnsureCreated(); + return context; + } + + private static Unit InitializeDb(TvContext context) => + DbInitializer.Initialize(context); + + private static Unit LogException(Exception ex, IServiceProvider provider) + { + provider.GetRequiredService>() + .LogError(ex, "Error occured while seeding database"); + return unit; + } + } +} diff --git a/ErsatzTV/Extensions/ListToActionResult.cs b/ErsatzTV/Extensions/ListToActionResult.cs new file mode 100644 index 000000000..47bb32f16 --- /dev/null +++ b/ErsatzTV/Extensions/ListToActionResult.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using LanguageExt; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Extensions +{ + public static class ListToActionResult + { + public static Task ToActionResult(this Task> list) => + list.Map, IActionResult>(l => new OkObjectResult(l)); + } +} diff --git a/ErsatzTV/Extensions/OptionToActionResult.cs b/ErsatzTV/Extensions/OptionToActionResult.cs new file mode 100644 index 000000000..8983a907a --- /dev/null +++ b/ErsatzTV/Extensions/OptionToActionResult.cs @@ -0,0 +1,16 @@ +using System.Threading.Tasks; +using LanguageExt; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Extensions +{ + public static class OptionToActionResult + { + public static IActionResult ToActionResult(this Option option) => + option.Match( + t => new OkObjectResult(t), + () => new NotFoundResult()); + + public static Task ToActionResult(this Task> option) => option.Map(ToActionResult); + } +} diff --git a/ErsatzTV/Extensions/ValidationToActionResult.cs b/ErsatzTV/Extensions/ValidationToActionResult.cs new file mode 100644 index 000000000..64462e6c4 --- /dev/null +++ b/ErsatzTV/Extensions/ValidationToActionResult.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using ErsatzTV.Core; +using LanguageExt; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Extensions +{ + public static class ValidationToActionResult + { + public static IActionResult ToActionResult(this Validation validation) => + validation.Match( + t => new OkObjectResult(t), + e => new BadRequestObjectResult(e)); + + public static Task ToActionResult(this Task> validation) => + validation.Map(ToActionResult); + + public static Task ToActionResult(this Task> validation) => + validation.Bind(ToActionResult); + + private static Task ToActionResult(Validation validation) => + validation.MatchAsync( + async t => + { + await t; + return new OkResult(); + }, + e => new BadRequestObjectResult(e)); + } +} diff --git a/ErsatzTV/Formatters/ChannelGuideOutputFormatter.cs b/ErsatzTV/Formatters/ChannelGuideOutputFormatter.cs new file mode 100644 index 000000000..a1eb9cf77 --- /dev/null +++ b/ErsatzTV/Formatters/ChannelGuideOutputFormatter.cs @@ -0,0 +1,27 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using ErsatzTV.Core.Iptv; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace ErsatzTV.Formatters +{ + public class ChannelGuideOutputFormatter : TextOutputFormatter + { + public ChannelGuideOutputFormatter() + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/xml")); + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } + + protected override bool CanWriteType(Type type) => typeof(ChannelGuide).IsAssignableFrom(type); + + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) => + // ReSharper disable once PossibleNullReferenceException + context.HttpContext.Response.WriteAsync(((ChannelGuide) context.Object).ToXml()); + } +} diff --git a/ErsatzTV/Formatters/ChannelPlaylistOutputFormatter.cs b/ErsatzTV/Formatters/ChannelPlaylistOutputFormatter.cs new file mode 100644 index 000000000..59e90390a --- /dev/null +++ b/ErsatzTV/Formatters/ChannelPlaylistOutputFormatter.cs @@ -0,0 +1,27 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using ErsatzTV.Core.Iptv; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace ErsatzTV.Formatters +{ + public class ChannelPlaylistOutputFormatter : TextOutputFormatter + { + public ChannelPlaylistOutputFormatter() + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/x-mpegurl")); + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } + + protected override bool CanWriteType(Type type) => typeof(ChannelPlaylist).IsAssignableFrom(type); + + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) => + // ReSharper disable once PossibleNullReferenceException + context.HttpContext.Response.WriteAsync(((ChannelPlaylist) context.Object).ToM3U()); + } +} diff --git a/ErsatzTV/Formatters/ConcatPlaylistOutputFormatter.cs b/ErsatzTV/Formatters/ConcatPlaylistOutputFormatter.cs new file mode 100644 index 000000000..b03fdc121 --- /dev/null +++ b/ErsatzTV/Formatters/ConcatPlaylistOutputFormatter.cs @@ -0,0 +1,27 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using ErsatzTV.Core.FFmpeg; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace ErsatzTV.Formatters +{ + public class ConcatPlaylistOutputFormatter : TextOutputFormatter + { + public ConcatPlaylistOutputFormatter() + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/plain")); + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } + + protected override bool CanWriteType(Type type) => typeof(ConcatPlaylist).IsAssignableFrom(type); + + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) => + // ReSharper disable once PossibleNullReferenceException + context.HttpContext.Response.WriteAsync(((ConcatPlaylist) context.Object).ToString()); + } +} diff --git a/ErsatzTV/Formatters/DeviceXmlOutputFormatter.cs b/ErsatzTV/Formatters/DeviceXmlOutputFormatter.cs new file mode 100644 index 000000000..33eddbd1b --- /dev/null +++ b/ErsatzTV/Formatters/DeviceXmlOutputFormatter.cs @@ -0,0 +1,27 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using ErsatzTV.Core.Hdhr; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace ErsatzTV.Formatters +{ + public class DeviceXmlOutputFormatter : TextOutputFormatter + { + public DeviceXmlOutputFormatter() + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/xml")); + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } + + protected override bool CanWriteType(Type type) => typeof(DeviceXml).IsAssignableFrom(type); + + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) => + // ReSharper disable once PossibleNullReferenceException + context.HttpContext.Response.WriteAsync(((DeviceXml) context.Object).ToXml()); + } +} diff --git a/ErsatzTV/Formatters/HdhrJsonOutputFormatter.cs b/ErsatzTV/Formatters/HdhrJsonOutputFormatter.cs new file mode 100644 index 000000000..a5de3ac28 --- /dev/null +++ b/ErsatzTV/Formatters/HdhrJsonOutputFormatter.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using ErsatzTV.Core.Hdhr; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace ErsatzTV.Formatters +{ + public class HdhrJsonOutputFormatter : TextOutputFormatter + { + private readonly JsonSerializerSettings _jsonSerializerSettings; + + public HdhrJsonOutputFormatter() + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json")); + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + + _jsonSerializerSettings = new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new DefaultNamingStrategy() + } + }; + } + + protected override bool CanWriteType(Type type) => + typeof(Discover).IsAssignableFrom(type) || typeof(LineupStatus).IsAssignableFrom(type) || + typeof(IEnumerable).IsAssignableFrom(type); + + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) => + // ReSharper disable once PossibleNullReferenceException + context.HttpContext.Response.WriteAsync( + JsonConvert.SerializeObject(context.Object, _jsonSerializerSettings)); + } +} diff --git a/ErsatzTV/Models/UI/MediaItemExtensions.cs b/ErsatzTV/Models/UI/MediaItemExtensions.cs new file mode 100644 index 000000000..46f41f351 --- /dev/null +++ b/ErsatzTV/Models/UI/MediaItemExtensions.cs @@ -0,0 +1,25 @@ +using ErsatzTV.Core.Domain; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Models.UI +{ + public static class MediaItemExtensions + { + public static string GetDisplayDuration(this MediaItem mediaItem) => + string.Format( + mediaItem.Metadata.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}", + mediaItem.Metadata.Duration); + + public static string GetDisplayTitle(this MediaItem mediaItem) => + mediaItem.Metadata.MediaType == MediaType.TvShow && + Optional(mediaItem.Metadata.SeasonNumber).IsSome && + Optional(mediaItem.Metadata.EpisodeNumber).IsSome + ? $"{mediaItem.Metadata.Title} s{mediaItem.Metadata.SeasonNumber:00}e{mediaItem.Metadata.EpisodeNumber:00}" + : mediaItem.Metadata.Title; + + public static string GetDisplayMediaType(this MediaItem mediaItem) => + mediaItem.Metadata.MediaType == MediaType.TvShow + ? "TV Show" + : mediaItem.Metadata.MediaType.ToString(); + } +} diff --git a/ErsatzTV/Pages/ChannelEditor.razor b/ErsatzTV/Pages/ChannelEditor.razor new file mode 100644 index 000000000..b15eb3d18 --- /dev/null +++ b/ErsatzTV/Pages/ChannelEditor.razor @@ -0,0 +1,148 @@ +@page "/channels/{Id:int?}" +@page "/channels/add" +@using ErsatzTV.Application.FFmpegProfiles +@using ErsatzTV.Application.FFmpegProfiles.Queries +@using ErsatzTV.Application.Images.Commands +@using ErsatzTV.Application.Channels +@using ErsatzTV.Application.Channels.Queries +@inject NavigationManager NavigationManager +@inject ILogger Logger +@inject ISnackbar Snackbar +@inject IMediator Mediator + +
+ @(IsEdit ? "Edit Channel" : "Add Channel") + + + + + + + + + @foreach (StreamingMode streamingMode in Enum.GetValues()) + { + @streamingMode + } + + + @foreach (FFmpegProfileViewModel profile in _ffmpegProfiles) + { + @profile.Name + } + + + + + + + Upload Logo + + + + + + + @(IsEdit ? "Save Changes" : "Add Channel") + + + + +
+ +@code { + + [Parameter] + public int? Id { get; set; } + + private readonly ChannelEditViewModel _model = new(); + private EditContext _editContext; + private ValidationMessageStore _messageStore; + + private List _ffmpegProfiles; + + protected override async Task OnParametersSetAsync() + { + await LoadFFmpegProfilesAsync(); + + if (Id.HasValue) + { + Option maybeChannel = await Mediator.Send(new GetChannelById(Id.Value)); + maybeChannel.Match( + channelViewModel => + { + _model.Id = channelViewModel.Id; + _model.Name = channelViewModel.Name; + _model.Number = channelViewModel.Number; + _model.FFmpegProfileId = channelViewModel.FFmpegProfileId; + _model.Logo = channelViewModel.Logo; + _model.StreamingMode = channelViewModel.StreamingMode; + }, + () => NavigationManager.NavigateTo("404")); + } + else + { + // TODO: command for new channel + int maxNumber = await Mediator.Send(new GetAllChannels()).Map(channels => channels.Max(c => c.Number)); + _model.Number = maxNumber + 1; + _model.Name = "New Channel"; + _model.FFmpegProfileId = _ffmpegProfiles.Head().Id; + _model.StreamingMode = StreamingMode.TransportStream; + } + } + + protected override void OnInitialized() + { + _editContext = new EditContext(_model); + _messageStore = new ValidationMessageStore(_editContext); + } + + private bool IsEdit => Id.HasValue; + + private async Task LoadFFmpegProfilesAsync() => + _ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles()); + + private async Task HandleSubmitAsync() + { + _messageStore.Clear(); + if (_editContext.Validate()) + { + Seq errorMessage = IsEdit ? + (await Mediator.Send(_model.ToUpdate())).LeftToSeq() : + (await Mediator.Send(_model.ToCreate())).LeftToSeq(); + + errorMessage.HeadOrNone().Match( + error => + { + Snackbar.Add($"Unexpected error saving channel: {error.Value}"); + Logger.LogError("Unexpected error saving channel: {Error}", error.Value); + }, + () => NavigationManager.NavigateTo("/channels")); + } + } + + private async Task UploadLogo(InputFileChangeEventArgs e) + { + var buffer = new byte[e.File.Size]; + await e.File.OpenReadStream().ReadAsync(buffer); + Either maybeCacheFileName = await Mediator.Send(new SaveImageToDisk(buffer)); + maybeCacheFileName.Match( + relativeFileName => _model.Logo = relativeFileName, + error => + { + Snackbar.Add($"Unexpected error saving channel logo: {error.Value}"); + Logger.LogError("Unexpected error saving channel logo: {Error}", error.Value); + }); + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/Channels.razor b/ErsatzTV/Pages/Channels.razor new file mode 100644 index 000000000..d49157243 --- /dev/null +++ b/ErsatzTV/Pages/Channels.razor @@ -0,0 +1,91 @@ +@page "/channels" +@using ErsatzTV.Application.Channels +@using ErsatzTV.Application.Channels.Commands +@using ErsatzTV.Application.Channels.Queries +@using ErsatzTV.Application.FFmpegProfiles +@using ErsatzTV.Application.FFmpegProfiles.Queries +@inject IDialogService Dialog +@inject IMediator Mediator + + + + Channels + + + + + + + + + + + + Number + + Logo + + Name + + Streaming Mode + FFmpeg Profile + + + + @context.Number + + @if (!string.IsNullOrWhiteSpace(context.Logo)) + { + + } + + @context.Name + @context.StreamingMode + + @if (context.StreamingMode == StreamingMode.TransportStream) + { + @_ffmpegProfiles.Find(p => p.Id == context.FFmpegProfileId)?.Name + } + + + + + Edit + + + Delete + + + + + + + Add Channel + + +@code { + private List _channels; + private List _ffmpegProfiles; + + protected override async Task OnParametersSetAsync() + { + _ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles()); + await LoadChannelsAsync(); + } + + private async Task DeleteChannelAsync(ChannelViewModel channel) + { + var parameters = new DialogParameters { { "EntityType", "channel" }, { "EntityName", channel.Name } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Delete Channel", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled) + { + await Mediator.Send(new DeleteChannel(channel.Id)); + await LoadChannelsAsync(); + } + } + + private async Task LoadChannelsAsync() => _channels = await Mediator.Send(new GetAllChannels()); +} \ No newline at end of file diff --git a/ErsatzTV/Pages/FFmpeg.razor b/ErsatzTV/Pages/FFmpeg.razor new file mode 100644 index 000000000..b5290a414 --- /dev/null +++ b/ErsatzTV/Pages/FFmpeg.razor @@ -0,0 +1,128 @@ +@page "/ffmpeg" +@using ErsatzTV.Application.FFmpegProfiles +@using ErsatzTV.Application.FFmpegProfiles.Commands +@using ErsatzTV.Application.FFmpegProfiles.Queries +@inject IDialogService Dialog +@inject IMediator Mediator + + + + + FFmpeg Settings + + + + + + + + + + + @foreach (FFmpegProfileViewModel profile in _ffmpegProfiles) + { + @profile.Name + } + + + + + + Save Settings + + + + + + FFmpeg Profiles + + Colored settings will be normalized + + + + + + + + + + + Name + Transcode + Resolution + Video Codec + Audio Codec + + + + @context.Name + + @(context.Transcode ? "Yes" : "No") + + + + @context.Resolution.Name + + + + + @context.VideoCodec + + + + + @context.AudioCodec + + + + + + Edit + + + Delete + + + + + + + Add Profile + + +@code { + + private bool _success; + private FFmpegSettingsViewModel _ffmpegSettings; + + private List _ffmpegProfiles; + + protected override async Task OnParametersSetAsync() + { + _ffmpegSettings = await Mediator.Send(new GetFFmpegSettings()); + _success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath); + await LoadFFmpegProfilesAsync(); + } + + private Task SaveSettings() => Mediator.Send(new UpdateFFmpegSettings(_ffmpegSettings)); + + private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null; + + private async Task LoadFFmpegProfilesAsync() => + _ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles()); + + private async Task DeleteProfileAsync(FFmpegProfileViewModel ffmpegProfile) + { + var parameters = new DialogParameters { { "EntityType", "ffmpeg profile" }, { "EntityName", ffmpegProfile.Name } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Delete FFmpeg Profile", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled) + { + await Mediator.Send(new DeleteFFmpegProfile(ffmpegProfile.Id)); + await LoadFFmpegProfilesAsync(); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/FFmpegEditor.razor b/ErsatzTV/Pages/FFmpegEditor.razor new file mode 100644 index 000000000..03dc4c681 --- /dev/null +++ b/ErsatzTV/Pages/FFmpegEditor.razor @@ -0,0 +1,150 @@ +@page "/ffmpeg/{Id:int}" +@page "/ffmpeg/add" +@using ErsatzTV.Application.Resolutions +@using ErsatzTV.Application.Resolutions.Queries +@using ErsatzTV.Application.FFmpegProfiles +@using ErsatzTV.Application.FFmpegProfiles.Commands +@using ErsatzTV.Application.FFmpegProfiles.Queries +@inject NavigationManager NavigationManager +@inject ILogger Logger +@inject ISnackbar Snackbar +@inject IMediator Mediator + + + + + + + @(IsEdit ? "Edit FFmpeg Profile" : "Add FFmpeg Profile") + + + + + + + + General + + + + + + + @foreach (ResolutionViewModel resolution in _resolutions) + { + @resolution.Name + } + + + + + + + + Video + + + + + + + + + + Audio + + + + + + + + + + + + + + + + + + + Normalization + + + + + + + + + + + + + + + + + + @(IsEdit ? "Save Changes" : "Add Profile") + + + + + + +@code { + + [Parameter] + public int Id { get; set; } + + private FFmpegProfileEditViewModel _model = new(); + private EditContext _editContext; + private ValidationMessageStore _messageStore; + + private List _resolutions; + + protected override async Task OnParametersSetAsync() + { + _resolutions = await Mediator.Send(new GetAllResolutions()); + + if (IsEdit) + { + Option profile = await Mediator.Send(new GetFFmpegProfileById(Id)); + profile.Match( + ffmpegProfileViewModel => _model = new FFmpegProfileEditViewModel(ffmpegProfileViewModel), + () => NavigationManager.NavigateTo("404")); + } + else + { + _model = new FFmpegProfileEditViewModel(await Mediator.Send(new NewFFmpegProfile())); + } + + _editContext = new EditContext(_model); + _messageStore = new ValidationMessageStore(_editContext); + } + + private bool IsEdit => Id != 0; + + private async Task HandleSubmitAsync() + { + _messageStore.Clear(); + if (_editContext.Validate()) + { + Seq errorMessage = IsEdit ? + (await Mediator.Send(_model.ToUpdate())).LeftToSeq() : + (await Mediator.Send(_model.ToCreate())).LeftToSeq(); + + errorMessage.HeadOrNone().Match( + error => + { + Snackbar.Add("Unexpected error saving ffmpeg profile"); + Logger.LogError("Unexpected error saving ffmpeg profile: {Error}", error.Value); + }, + () => NavigationManager.NavigateTo("/ffmpeg")); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/Index.razor b/ErsatzTV/Pages/Index.razor new file mode 100644 index 000000000..08406b24e --- /dev/null +++ b/ErsatzTV/Pages/Index.razor @@ -0,0 +1,7 @@ +@page "/" + + + + Welcome to ErsatzTV! + + \ No newline at end of file diff --git a/ErsatzTV/Pages/LocalMediaSourceEditor.razor b/ErsatzTV/Pages/LocalMediaSourceEditor.razor new file mode 100644 index 000000000..71680811f --- /dev/null +++ b/ErsatzTV/Pages/LocalMediaSourceEditor.razor @@ -0,0 +1,88 @@ +@page "/media/sources/local/add" +@using ErsatzTV.Application +@using ErsatzTV.Application.MediaSources +@using ErsatzTV.Application.MediaSources.Commands +@inject NavigationManager NavigationManager +@inject ILogger Logger +@inject ISnackbar Snackbar +@inject IMediator Mediator +@inject ChannelWriter Channel + +
+ Add Local Media Source + + + + + + + @foreach (MediaType mediaType in Enum.GetValues()) + { + @mediaType + } + + @* TODO: replace this with a folder picker *@ + + + + + Add Local Media Source + + + + +
+ +@code { + + [Parameter] + public int Id { get; set; } + + private readonly LocalMediaSourceEditViewModel _model = new(); + private EditContext _editContext; + private ValidationMessageStore _messageStore; + + private LocalMediaSource _mediaSource; + + protected override Task OnParametersSetAsync() + { + _mediaSource = new LocalMediaSource + { + MediaType = MediaType.TvShow + }; + + _model.MediaType = _mediaSource.MediaType; + _model.Folder = _mediaSource.Folder; + + return Task.CompletedTask; + } + + protected override void OnInitialized() + { + _editContext = new EditContext(_model); + _messageStore = new ValidationMessageStore(_editContext); + } + + private async Task HandleSubmitAsync() + { + _messageStore.Clear(); + if (_editContext.Validate()) + { + var command = new CreateLocalMediaSource(_model.Folder, _model.MediaType, _model.Folder); + Either result = await Mediator.Send(command); + await result.Match( + Left: error => + { + Snackbar.Add(error.Value, Severity.Error); + Logger.LogError("Unexpected error saving simple media collection: {Error}", error.Value); + return Task.CompletedTask; + }, + Right: async vm => + { + await Channel.WriteAsync(new ScanLocalMediaSource(vm.Id)); + NavigationManager.NavigateTo("/media/sources"); + }); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaCollectionEditor.razor b/ErsatzTV/Pages/MediaCollectionEditor.razor new file mode 100644 index 000000000..2bd73f8a1 --- /dev/null +++ b/ErsatzTV/Pages/MediaCollectionEditor.razor @@ -0,0 +1,82 @@ +@page "/media/collections/{Id:int}" +@page "/media/collections/add" +@using ErsatzTV.Application.MediaCollections +@using ErsatzTV.Application.MediaCollections.Commands +@using ErsatzTV.Application.MediaCollections.Queries +@inject NavigationManager NavigationManager +@inject ILogger Logger +@inject ISnackbar Snackbar +@inject IMediator Mediator + +
+ @(IsEdit ? "Edit Media Collection" : "Add Media Collection") + + + + + + + + + + @(IsEdit ? "Save Changes" : "Add Media Collection") + + + + +
+ +@code { + + [Parameter] + public int Id { get; set; } + + private readonly SimpleMediaCollectionEditViewModel _model = new(); + private EditContext _editContext; + private ValidationMessageStore _messageStore; + + protected override async Task OnParametersSetAsync() + { + if (IsEdit) + { + Option maybeCollection = await Mediator.Send(new GetSimpleMediaCollectionById(Id)); + maybeCollection.IfSome(collection => + { + _model.Id = collection.Id; + _model.Name = collection.Name; + }); + } + else + { + _model.Name = "New Collection"; + } + } + + protected override void OnInitialized() + { + _editContext = new EditContext(_model); + _messageStore = new ValidationMessageStore(_editContext); + } + + private bool IsEdit => Id != 0; + + private async Task HandleSubmitAsync() + { + _messageStore.Clear(); + if (_editContext.Validate()) + { + Seq errorMessage = IsEdit ? + (await Mediator.Send(new UpdateSimpleMediaCollection(Id, _model.Name))).LeftToSeq() : + (await Mediator.Send(new CreateSimpleMediaCollection(_model.Name))).LeftToSeq(); + + errorMessage.HeadOrNone().Match( + error => + { + Snackbar.Add(error.Value, Severity.Error); + Logger.LogError("Error saving simple media collection: {Error}", error.Value); + }, + () => NavigationManager.NavigateTo("/media/collections")); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaCollectionItemsEditor.razor b/ErsatzTV/Pages/MediaCollectionItemsEditor.razor new file mode 100644 index 000000000..a1889e5c2 --- /dev/null +++ b/ErsatzTV/Pages/MediaCollectionItemsEditor.razor @@ -0,0 +1,126 @@ +@page "/media/collections/{Id:int}/items" +@inject IDbContextFactory DbFactory +@inject NavigationManager NavigationManager + + + + @_mediaCollection.Name Media Items + + + Source + Type + Title + Duration + + + @context.Source.Name + @context.GetDisplayMediaType() + @context.GetDisplayTitle() + @context.Metadata.Duration.ToString(@"hh\:mm\:ss\.fff") + + + @if (_mediaCollection.Items.Count > 0) + { + + } + + + + + + All Media Items + + + + + + Source + Type + Title + Duration + + + @context.Source.Name + @context.GetDisplayMediaType() + @context.GetDisplayTitle() + @context.GetDisplayDuration() + + + + + + + Add Results + + +@code { + + [Parameter] + public int Id { get; set; } + + private SimpleMediaCollection _mediaCollection; + + protected override async Task OnParametersSetAsync() => await LoadMediaCollectionAsync(); + + private List _mediaItemIds; + private IEnumerable _pagedData; + private MudTable _table; + + private int _totalItems; + private string _searchString; + + private async Task> ServerReload(TableState state) + { + await using TvContext context = DbFactory.CreateDbContext(); + IQueryable data = from c in context.MediaItems.Include(c => c.Source) select c; + + if (!string.IsNullOrEmpty(_searchString)) + { + data = data.Where(c => EF.Functions.Like(c.Metadata.Title, $"%{_searchString}%")); + } + + _mediaItemIds = data.Map(c => c.Id).ToList(); + _totalItems = data.Count(); + + _pagedData = data.OrderBy(c => c.Id).Skip(state.Page * state.PageSize).Take(state.PageSize).ToArray(); + return new TableData { TotalItems = _totalItems, Items = _pagedData }; + } + + private void OnSearch(string text) + { + _searchString = text; + _table.ReloadServerData(); + } + + private async Task AddResultsAsync() + { + await using TvContext context = DbFactory.CreateDbContext(); + + SimpleMediaCollection mediaCollection = await context.SimpleMediaCollections.FindAsync(_mediaCollection.Id); + await context.Entry(mediaCollection).Collection(cg => cg.Items).LoadAsync(); + IEnumerable existingMediaItems = _mediaCollection.Items.Select(c => c.Id); + IQueryable mediaItemsToAdd = from c in context.MediaItems + where _mediaItemIds.Contains(c.Id) && !existingMediaItems.Contains(c.Id) + select c; + foreach (MediaItem mediaItem in mediaItemsToAdd) + { + mediaCollection.Items.Add(mediaItem); + } + context.MediaCollections.Update(mediaCollection); + await context.SaveChangesAsync(); + + await LoadMediaCollectionAsync(); + } + + private async Task LoadMediaCollectionAsync() + { + await using TvContext context = DbFactory.CreateDbContext(); + _mediaCollection = await context.SimpleMediaCollections + .AsNoTracking() + .Include(cg => cg.Items) + .ThenInclude(c => c.Source) + .FirstAsync(cg => cg.Id == Id); + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaCollections.razor b/ErsatzTV/Pages/MediaCollections.razor new file mode 100644 index 000000000..00943f2f0 --- /dev/null +++ b/ErsatzTV/Pages/MediaCollections.razor @@ -0,0 +1,104 @@ +@page "/media/collections" +@using ErsatzTV.Application.MediaCollections +@using ErsatzTV.Application.MediaCollections.Commands +@using ErsatzTV.Application.MediaCollections.Queries +@inject IDialogService Dialog +@inject IMediator Mediator + + + + Media Collections + + + + + + + + + + + Name + Media Items + + + + + @if (context.IsSimple) + { + @context.Name + } + else + { + @context.Name + } + + @context.ItemCount + + @if (context.IsSimple) + { + + + Edit Properties + + + Edit Media Items + + + Delete + + + } + + + + + + + + Add Media Collection + + +@code { + private IEnumerable _pagedData; + private MudTable _table; + + private int _totalItems; + private string _searchString; + + private async Task DeleteMediaCollectionAsync(MediaCollectionSummaryViewModel mediaCollection) + { + if (mediaCollection.IsSimple) + { + var parameters = new DialogParameters { { "EntityType", "media collection" }, { "EntityName", mediaCollection.Name } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Delete Media Collection", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled) + { + await Mediator.Send(new DeleteSimpleMediaCollection(mediaCollection.Id)); + _table.ReloadServerData(); + } + } + } + + private async Task> ServerReload(TableState state) + { + List aggregateData = + await Mediator.Send(new GetMediaCollectionSummaries(_searchString)); + + _totalItems = aggregateData.Count; + + _pagedData = aggregateData.Skip(state.Page * state.PageSize).Take(state.PageSize); + return new TableData { TotalItems = _totalItems, Items = _pagedData }; + } + + private void OnSearch(string text) + { + _searchString = text; + _table.ReloadServerData(); + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaItems.razor b/ErsatzTV/Pages/MediaItems.razor new file mode 100644 index 000000000..5dd78d151 --- /dev/null +++ b/ErsatzTV/Pages/MediaItems.razor @@ -0,0 +1,18 @@ +@page "/media/items" +@inject IDbContextFactory DbFactory + + + + + + + + + + + + + +@code { + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaSources.razor b/ErsatzTV/Pages/MediaSources.razor new file mode 100644 index 000000000..cef3d2ffe --- /dev/null +++ b/ErsatzTV/Pages/MediaSources.razor @@ -0,0 +1,14 @@ +@page "/media/sources" + + + + + + @* *@ + @* *@ + @* *@ + + +@code { + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/Playouts.razor b/ErsatzTV/Pages/Playouts.razor new file mode 100644 index 000000000..6e0672423 --- /dev/null +++ b/ErsatzTV/Pages/Playouts.razor @@ -0,0 +1,56 @@ +@page "/playouts" +@using ErsatzTV.Application.Playouts +@using ErsatzTV.Application.Playouts.Queries +@inject IMediator Mediator + + + + Playouts + + + Id + Channel + Schedule + Playout Type + + + @context.Id + @context.Channel.Name + @context.ProgramSchedule.Name + @context.ProgramSchedulePlayoutType + + + +@if (_selectedPlayoutItems != null) +{ + + + Playout Detail + + + Start + Media Item + Duration + + + @context.Start.ToString("G") + @context.Title + @context.Duration + + + + + +} + +@code { + private List _playouts; + private List _selectedPlayoutItems; + + protected override async Task OnParametersSetAsync() => + _playouts = await Mediator.Send(new GetAllPlayouts()); + + private async Task PlayoutSelected(PlayoutViewModel playout) => + _selectedPlayoutItems = await Mediator.Send(new GetPlayoutItemsById(playout.Id)); + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/ScheduleEditor.razor b/ErsatzTV/Pages/ScheduleEditor.razor new file mode 100644 index 000000000..98e148bb7 --- /dev/null +++ b/ErsatzTV/Pages/ScheduleEditor.razor @@ -0,0 +1,91 @@ +@page "/schedules/{Id:int}" +@page "/schedules/add" +@using ErsatzTV.Application.ProgramSchedules +@using ErsatzTV.Application.ProgramSchedules.Queries +@inject NavigationManager NavigationManager +@inject ILogger Logger +@inject ISnackbar Snackbar +@inject IMediator Mediator + +
+ @(IsEdit ? "Edit Schedule" : "Add Schedule") + + + + + + + + @foreach (PlaybackOrder playbackOrder in Enum.GetValues()) + { + @playbackOrder + } + + + + + @(IsEdit ? "Save Changes" : "Add Schedule") + + + + +
+ +@code { + + [Parameter] + public int Id { get; set; } + + private readonly ProgramScheduleEditViewModel _model = new(); + private EditContext _editContext; + private ValidationMessageStore _messageStore; + + protected override async Task OnParametersSetAsync() + { + if (IsEdit) + { + Option maybeProgramSchedule = await Mediator.Send(new GetProgramScheduleById(Id)); + maybeProgramSchedule.Match( + viewModel => + { + _model.Id = viewModel.Id; + _model.Name = viewModel.Name; + _model.MediaCollectionPlaybackOrder = viewModel.MediaCollectionPlaybackOrder; + }, + () => NavigationManager.NavigateTo("404")); + } + else + { + _model.Name = "New Schedule"; + _model.MediaCollectionPlaybackOrder = PlaybackOrder.Shuffle; + } + } + + protected override void OnInitialized() + { + _editContext = new EditContext(_model); + _messageStore = new ValidationMessageStore(_editContext); + } + + private bool IsEdit => Id > 0; + + private async Task HandleSubmitAsync() + { + _messageStore.Clear(); + if (_editContext.Validate()) + { + Seq errorMessage = IsEdit ? + (await Mediator.Send(_model.ToUpdate())).LeftToSeq() : + (await Mediator.Send(_model.ToCreate())).LeftToSeq(); + + errorMessage.HeadOrNone().Match( + error => + { + Snackbar.Add(error.Value); + Logger.LogError("Unexpected error saving schedule: {Error}", error.Value); + }, + () => NavigationManager.NavigateTo("/schedules")); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/ScheduleItemsEditor.razor b/ErsatzTV/Pages/ScheduleItemsEditor.razor new file mode 100644 index 000000000..4c72fc7e6 --- /dev/null +++ b/ErsatzTV/Pages/ScheduleItemsEditor.razor @@ -0,0 +1,98 @@ +@page "/schedules/{Id:int}/items" +@using ErsatzTV.Application.ProgramSchedules +@using ErsatzTV.Application.ProgramSchedules.Commands +@using ErsatzTV.Application.ProgramSchedules.Queries +@using ErsatzTV.Application.MediaCollections +@using ErsatzTV.Application.MediaCollections.Queries +@inject IMediator Mediator + + + + @_schedule.Name Items + + + Start Time + Media Collection + Playout Mode + + + + @(context.StartType == StartType.Fixed ? context.StartTime : "Dynamic") + + @context.MediaCollectionName + @context.PlayoutMode + + @* *@ + @* *@ + @* Start Time *@ + @* *@ + @* *@ + @* *@ + @* *@ + @* *@ + @* *@ + @* @foreach (PlayoutMode playoutMode in Enum.GetValues()) *@ + @* { *@ + @* @playoutMode *@ + @* } *@ + @* *@ + @* *@ + @* *@ + + + + +@* TODO: only enable this button when media collections exist *@ + + Add Schedule Item + + +@code { + + [Parameter] + public int Id { get; set; } + + private ProgramScheduleItemsEditViewModel _schedule; + + protected override Task OnParametersSetAsync() => LoadScheduleItems(); + + private async Task LoadScheduleItems() + { + string name = string.Empty; + Option maybeSchedule = await Mediator.Send(new GetProgramScheduleById(Id)); + maybeSchedule.IfSome(vm => name = vm.Name); + + Option> maybeResults = await Mediator.Send(new GetProgramScheduleItems(Id)); + maybeResults.IfSome(items => _schedule = new ProgramScheduleItemsEditViewModel + { + Name = name, + Items = items.Map(i => new ProgramScheduleItemEditViewModel + { + Id = i.Id, + Index = i.Index, + StartType = i.StartType, + StartTime = i.StartTime, + PlayoutMode = i.PlayoutMode, + MediaCollectionName = i.MediaCollection.Name + }).ToList() + }); + } + + private async Task AddScheduleItem() + { + try + { + // TODO: don't bother going to the database for this, we probably want a "save changes" button + // to save all added, deleted, modified items + + MediaCollectionViewModel mediaCollection = await Mediator.Send(new GetAllMediaCollections()).Map(list => list.Head()); + await Mediator.Send(new AddProgramScheduleItem(Id, StartType.Dynamic, null, PlayoutMode.One, mediaCollection.Id, null, null, null)); + await LoadScheduleItems(); + } + catch (Exception ex) + { + // TODO: something? + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/Schedules.razor b/ErsatzTV/Pages/Schedules.razor new file mode 100644 index 000000000..732b5f1ae --- /dev/null +++ b/ErsatzTV/Pages/Schedules.razor @@ -0,0 +1,102 @@ +@page "/schedules" +@using ErsatzTV.Application.ProgramSchedules +@using ErsatzTV.Application.ProgramSchedules.Commands +@using ErsatzTV.Application.ProgramSchedules.Queries +@inject IDialogService Dialog +@inject IMediator Mediator + + + + Schedules + + + + + + + + + Id + Name + Media Collection Playback Order + + + + @context.Id + @context.Name + @context.MediaCollectionPlaybackOrder + + + + Edit Properties + + + Edit Schedule Items + + + Delete + + + + + + + Add Schedule + + +@if (_selectedScheduleItems != null) +{ + + + @_selectedSchedule.Name Items + + + Start Time + Media Collection + Playout Mode + + + + @(context.StartType == StartType.Fixed ? context.StartTime : "Dynamic") + + @context.MediaCollection.Name + @context.PlayoutMode + + + + + +} + +@code { + private List _schedules; + private List _selectedScheduleItems; + private ProgramScheduleViewModel _selectedSchedule; + + protected override Task OnParametersSetAsync() => LoadSchedules(); + + private async Task ScheduleSelected(ProgramScheduleViewModel schedule) + { + _selectedSchedule = schedule; + await Mediator.Send(new GetProgramScheduleItems(schedule.Id)) + .IterT(results => _selectedScheduleItems = results.ToList()); + } + + private async Task DeleteSchedule(ProgramScheduleViewModel programSchedule) + { + var parameters = new DialogParameters { { "EntityType", "schedule" }, { "EntityName", programSchedule.Name } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Delete Schedule", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled) + { + await Mediator.Send(new DeleteProgramSchedule(programSchedule.Id)); + await LoadSchedules(); + } + } + + private async Task LoadSchedules() => + _schedules = await Mediator.Send(new GetAllProgramSchedules()); + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/_Host.cshtml b/ErsatzTV/Pages/_Host.cshtml new file mode 100644 index 000000000..c46dd49f4 --- /dev/null +++ b/ErsatzTV/Pages/_Host.cshtml @@ -0,0 +1,37 @@ +@page "/" +@namespace ErsatzTV.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@{ + Layout = null; +} + + + + + + + ErsatzTV + + + + + + + + + +
+ + An error has occurred. This application may no longer respond until reloaded. + + + An unhandled exception has occurred. See browser dev tools for details. + + Reload + 🗙 +
+ + + + + \ No newline at end of file diff --git a/ErsatzTV/Program.cs b/ErsatzTV/Program.cs new file mode 100644 index 000000000..9b38eb74f --- /dev/null +++ b/ErsatzTV/Program.cs @@ -0,0 +1,56 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using ErsatzTV.Extensions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace ErsatzTV +{ + public class Program + { + public static IConfiguration Configuration { get; } = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", false, true) + .AddJsonFile( + $"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", + true) + .AddEnvironmentVariables() + .Build(); + + public static async Task Main(string[] args) + { + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(Configuration) + .Enrich.FromLogContext() + .CreateLogger(); + + try + { + await CreateHostBuilder(args) + .Build() + .SeedDatabase() + .RunAsync(); + return 0; + } + catch (Exception ex) + { + Log.Fatal(ex, "Host terminated unexpectedly"); + return 1; + } + finally + { + Log.CloseAndFlush(); + } + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults( + webBuilder => webBuilder.UseStartup() + .UseKestrel(options => options.AddServerHeader = false)) + .UseSerilog(); + } +} diff --git a/ErsatzTV/Properties/launchSettings.json b/ErsatzTV/Properties/launchSettings.json new file mode 100644 index 000000000..5ed1cc6ec --- /dev/null +++ b/ErsatzTV/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "ErsatzTV": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://0.0.0.0:8989", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/ErsatzTV/Resources/ErsatzTV.png b/ErsatzTV/Resources/ErsatzTV.png new file mode 100644 index 0000000000000000000000000000000000000000..b053a5a66c13fdfb41801df374cecfcde4fa1e31 GIT binary patch literal 1615 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 literal 0 HcmV?d00001 diff --git a/ErsatzTV/Resources/background.png b/ErsatzTV/Resources/background.png new file mode 100644 index 0000000000000000000000000000000000000000..c2cf65f42e94e64a43b68cd78601e768e0283670 GIT binary patch literal 14272 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 NamingStrategy = new CustomNamingStrategy(); + } +} diff --git a/ErsatzTV/Serialization/CustomNamingStrategy.cs b/ErsatzTV/Serialization/CustomNamingStrategy.cs new file mode 100644 index 000000000..a38495baa --- /dev/null +++ b/ErsatzTV/Serialization/CustomNamingStrategy.cs @@ -0,0 +1,18 @@ +using System; +using Newtonsoft.Json.Serialization; + +namespace ErsatzTV.Serialization +{ + public class CustomNamingStrategy : CamelCaseNamingStrategy + { + protected override string ResolvePropertyName(string name) + { + if (name.Equals("FFmpegProfileId", StringComparison.OrdinalIgnoreCase)) + { + return "ffmpegProfileId"; + } + + return base.ResolvePropertyName(name); + } + } +} diff --git a/ErsatzTV/Services/FFmpegLocatorService.cs b/ErsatzTV/Services/FFmpegLocatorService.cs new file mode 100644 index 000000000..fd0e814dd --- /dev/null +++ b/ErsatzTV/Services/FFmpegLocatorService.cs @@ -0,0 +1,46 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.FFmpeg; +using LanguageExt; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Services +{ + public class FFmpegLocatorService : IHostedService + { + private readonly ILogger _logger; + private readonly IServiceScopeFactory _serviceScopeFactory; + + public FFmpegLocatorService( + IServiceScopeFactory serviceScopeFactory, + ILogger logger) + { + _serviceScopeFactory = serviceScopeFactory; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IFFmpegLocator ffmpegLocator = scope.ServiceProvider.GetRequiredService(); + + // check for ffmpeg and ffprobe in the last known/configured location + // otherwise search using which/where and save any located executables + Option maybeFFmpegPath = await ffmpegLocator.ValidatePath("ffmpeg", ConfigElementKey.FFmpegPath); + maybeFFmpegPath.Match( + path => _logger.LogInformation("Located ffmpeg at {Path}", path), + () => _logger.LogWarning("Failed to locate ffmpeg executable")); + + Option maybeFFprobePath = + await ffmpegLocator.ValidatePath("ffprobe", ConfigElementKey.FFprobePath); + maybeFFprobePath.Match( + path => _logger.LogInformation("Located ffprobe at {Path}", path), + () => _logger.LogWarning("Failed to locate ffprobe executable")); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/ErsatzTV/Services/PlexService.cs b/ErsatzTV/Services/PlexService.cs new file mode 100644 index 000000000..9b7a3055e --- /dev/null +++ b/ErsatzTV/Services/PlexService.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Application; +using ErsatzTV.Application.MediaSources.Commands; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Services +{ + public class PlexService : BackgroundService + { + private readonly ChannelReader _channel; + private readonly ILogger _logger; + private readonly IServiceScopeFactory _serviceScopeFactory; + + public PlexService( + ChannelReader channel, + IServiceScopeFactory serviceScopeFactory, + ILogger logger) + { + _channel = channel; + _serviceScopeFactory = serviceScopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + if (!File.Exists(FileSystemLayout.PlexSecretsPath)) + { + await File.WriteAllTextAsync(FileSystemLayout.PlexSecretsPath, "{}", cancellationToken); + } + + _logger.LogInformation( + "Plex service started; secrets are at {PlexSecretsPath}", + FileSystemLayout.PlexSecretsPath); + + // synchronize sources on startup + List sources = await SynchronizeSources( + new SynchronizePlexMediaSources(), + cancellationToken); + foreach (PlexMediaSource source in sources) + { + await SynchronizeLibraries(new SynchronizePlexLibraries(source.Id), cancellationToken); + } + + await foreach (IPlexBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken)) + { + try + { + Task requestTask = request switch + { + TryCompletePlexPinFlow pinRequest => CompletePinFlow(pinRequest, cancellationToken), + SynchronizePlexMediaSources sourcesRequest => SynchronizeSources( + sourcesRequest, + cancellationToken) + }; + + await requestTask; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to process poll for Plex auth token request"); + } + } + } + + private async Task> SynchronizeSources( + SynchronizePlexMediaSources request, + CancellationToken cancellationToken) + { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IMediator mediator = scope.ServiceProvider.GetRequiredService(); + + Either> result = await mediator.Send(request, cancellationToken); + return result.Match( + sources => + { + if (sources.Any()) + { + _logger.LogInformation("Successfully synchronized plex media sources"); + } + + return sources; + }, + error => + { + _logger.LogWarning( + "Unable to synchronize plex media sources: {Error}", + error.Value); + return new List(); + }); + } + + private async Task CompletePinFlow( + TryCompletePlexPinFlow request, + CancellationToken cancellationToken) + { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IMediator mediator = scope.ServiceProvider.GetRequiredService(); + + Either result = await mediator.Send(request, cancellationToken); + result.BiIter( + success => + { + if (success) + { + _logger.LogInformation("Successfully authenticated with plex"); + } + else + { + _logger.LogInformation("Plex authentication timeout"); + } + }, + error => _logger.LogWarning("Unable to poll plex token: {Error}", error.Value)); + } + + private async Task SynchronizeLibraries(SynchronizePlexLibraries request, CancellationToken cancellationToken) + { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IMediator mediator = scope.ServiceProvider.GetRequiredService(); + + Either result = await mediator.Send(request, cancellationToken); + result.BiIter( + _ => _logger.LogInformation( + "Successfully synchronized plex libraries for source {MediaSourceId}", + request.PlexMediaSourceId), + error => _logger.LogWarning( + "Unable to synchronize plex libraries for source {MediaSourceId}: {Error}", + request.PlexMediaSourceId, + error.Value)); + } + } +} diff --git a/ErsatzTV/Services/SchedulerService.cs b/ErsatzTV/Services/SchedulerService.cs new file mode 100644 index 000000000..86e9cd1a4 --- /dev/null +++ b/ErsatzTV/Services/SchedulerService.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Application; +using ErsatzTV.Application.MediaSources.Commands; +using ErsatzTV.Application.Playouts.Commands; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ErsatzTV.Services +{ + public class SchedulerService : IHostedService + { + private readonly ChannelWriter _channel; + private readonly IServiceScopeFactory _serviceScopeFactory; + private Timer _timer; + + public SchedulerService( + IServiceScopeFactory serviceScopeFactory, + ChannelWriter channel) + { + _serviceScopeFactory = serviceScopeFactory; + _channel = channel; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _timer = new Timer( + async _ => await DoWork(cancellationToken), + null, + TimeSpan.FromSeconds(0), // fire immediately + TimeSpan.FromHours(1)); // repeat every hour + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _timer?.Change(Timeout.Infinite, 0); + + return Task.CompletedTask; + } + + private async Task DoWork(CancellationToken cancellationToken) + { + await BuildPlayouts(cancellationToken); + await ScanLocalMediaSources(cancellationToken); + } + + + private async Task BuildPlayouts(CancellationToken cancellationToken) + { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + TvContext dbContext = scope.ServiceProvider.GetRequiredService(); + + List playoutIds = await dbContext.Playouts.Map(p => p.Id).ToListAsync(cancellationToken); + foreach (int playoutId in playoutIds) + { + await _channel.WriteAsync(new BuildPlayout(playoutId), cancellationToken); + } + } + + private async Task ScanLocalMediaSources(CancellationToken cancellationToken) + { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + TvContext dbContext = scope.ServiceProvider.GetRequiredService(); + + List localMediaSourceIds = await dbContext.LocalMediaSources + .Map(ms => ms.Id) + .ToListAsync(cancellationToken); + + foreach (int mediaSourceId in localMediaSourceIds) + { + await _channel.WriteAsync(new ScanLocalMediaSource(mediaSourceId), cancellationToken); + } + } + } +} diff --git a/ErsatzTV/Services/WorkerService.cs b/ErsatzTV/Services/WorkerService.cs new file mode 100644 index 000000000..eb7f3fe31 --- /dev/null +++ b/ErsatzTV/Services/WorkerService.cs @@ -0,0 +1,102 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Application; +using ErsatzTV.Application.MediaItems.Commands; +using ErsatzTV.Application.MediaSources.Commands; +using ErsatzTV.Application.Playouts.Commands; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Services +{ + public class WorkerService : BackgroundService + { + private readonly ChannelReader _channel; + private readonly ILogger _logger; + private readonly IServiceScopeFactory _serviceScopeFactory; + + public WorkerService( + ChannelReader channel, + IServiceScopeFactory serviceScopeFactory, + ILogger logger) + { + _channel = channel; + _serviceScopeFactory = serviceScopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Worker service started"); + + await foreach (IBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken)) + { + try + { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IMediator mediator = scope.ServiceProvider.GetRequiredService(); + + switch (request) + { + case BuildPlayout buildPlayout: + Either buildPlayoutResult = await mediator.Send( + buildPlayout, + cancellationToken); + buildPlayoutResult.BiIter( + _ => _logger.LogDebug("Built playout {PlayoutId}", buildPlayout.PlayoutId), + error => _logger.LogWarning( + "Unable to build playout {PlayoutId}: {Error}", + buildPlayout.PlayoutId, + error.Value)); + break; + case RefreshMediaItem refreshMediaItem: + string type = refreshMediaItem switch + { + RefreshMediaItemMetadata => "metadata", + RefreshMediaItemStatistics => "statistics", + RefreshMediaItemCollections => "collections", + _ => "" + }; + + // TODO: different request types for different media source types? + Either refreshMediaItemResult = + await mediator.Send(refreshMediaItem, cancellationToken); + refreshMediaItemResult.Match( + _ => _logger.LogDebug( + $"Refreshed {type} for media item {{MediaItemId}}", + refreshMediaItem.MediaItemId), + error => _logger.LogWarning( + $"Unable to refresh {type} for media item {{MediaItemId}}: {{Error}}", + refreshMediaItem.MediaItemId, + error.Value)); + break; + case ScanLocalMediaSource scanLocalMediaSource: + Either scanResult = await mediator.Send( + scanLocalMediaSource, + cancellationToken); + scanResult.BiIter( + name => _logger.LogDebug( + "Done scanning local media source {MediaSource}", + name), + error => _logger.LogWarning( + "Unable to scan local media source {MediaSourceId}: {Error}", + scanLocalMediaSource.MediaSourceId, + error.Value)); + break; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to process background service request"); + } + } + } + } +} diff --git a/ErsatzTV/Shared/DeleteDialog.razor b/ErsatzTV/Shared/DeleteDialog.razor new file mode 100644 index 000000000..e72f94f87 --- /dev/null +++ b/ErsatzTV/Shared/DeleteDialog.razor @@ -0,0 +1,48 @@ + + + + + + @if (!string.IsNullOrWhiteSpace(DetailText)) + { + + + + } + + + Cancel + Delete + + + +@code { + + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } + + [Parameter] + public string EntityType { get; set; } + + [Parameter] + public string EntityName { get; set; } + + [Parameter] + public string DetailText { get; set; } + + [Parameter] + public string DetailHighlight { get; set; } + + private string FormatText() => $"Do you really want to delete the {EntityType} {EntityName}? This process cannot be undone."; + + private void Submit() => MudDialog.Close(DialogResult.Ok(true)); + + private void Cancel() => MudDialog.Cancel(); + +} \ No newline at end of file diff --git a/ErsatzTV/Shared/LocalMediaSources.razor b/ErsatzTV/Shared/LocalMediaSources.razor new file mode 100644 index 000000000..3eb06147b --- /dev/null +++ b/ErsatzTV/Shared/LocalMediaSources.razor @@ -0,0 +1,60 @@ +@using ErsatzTV.Application.MediaSources +@using ErsatzTV.Application.MediaSources.Commands +@using ErsatzTV.Application.MediaSources.Queries +@inject IDialogService Dialog +@inject IMediator Mediator + + + + Local Media Sources + + + + + + + Folder + + + + @context.Folder + + + + + + + Add Local Media Source + + +@code { + private IList _mediaSources; + + protected override async Task OnParametersSetAsync() => await LoadMediaSources(); + + private async Task LoadMediaSources() => + _mediaSources = await Mediator.Send(new GetAllMediaSources()).Map(list => list.OfType().ToList()); + + private async Task DeleteMediaSource(LocalMediaSourceViewModel mediaSource) + { + int count = await Mediator.Send(new CountMediaItemsById(mediaSource.Id)); + + var parameters = new DialogParameters + { + { "EntityType", "media source" }, + { "EntityName", mediaSource.Name }, + { "DetailText", $"This media source contains {count} media items." }, + { "DetailHighlight", count.ToString() } + }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Delete Media Source", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled) + { + await Mediator.Send(new DeleteLocalMediaSource(mediaSource.Id)); + await LoadMediaSources(); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Shared/MainLayout.razor b/ErsatzTV/Shared/MainLayout.razor new file mode 100644 index 000000000..63af96f76 --- /dev/null +++ b/ErsatzTV/Shared/MainLayout.razor @@ -0,0 +1,57 @@ +@inherits LayoutComponentBase + + + + + + + + + ErsatzTV + + + + + + Home + FFmpeg + Channels + + Media Sources + Media Items + Media Collections + + Schedules + Playouts + + + + + @Body + + + + +@code { + bool _drawerOpen = true; + + private void DrawerToggle() => _drawerOpen = !_drawerOpen; + + private MudTheme _ersatzTvTheme + { + get + { + var current = new MudTheme(); + + return new MudTheme + { + Palette = new Palette + { + DrawerBackground = current.Palette.Background, + Background = current.Palette.BackgroundGrey + } + }; + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Shared/MainLayout.razor.css b/ErsatzTV/Shared/MainLayout.razor.css new file mode 100644 index 000000000..566068fcf --- /dev/null +++ b/ErsatzTV/Shared/MainLayout.razor.css @@ -0,0 +1,58 @@ +.page { + display: flex; + flex-direction: column; + position: relative; +} + +.main { flex: 1; } + +.sidebar { background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); } + +.top-row { + align-items: center; + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + display: flex; + height: 3.5rem; + justify-content: flex-end; +} + +.top-row ::deep a, .top-row .btn-link { + margin-left: 1.5rem; + white-space: nowrap; +} + +.top-row a:first-child { + overflow: hidden; + text-overflow: ellipsis; +} + +@media (max-width: 640.98px) { + .top-row:not(.auth) { display: none; } + + .top-row.auth { justify-content: space-between; } + + .top-row a, .top-row .btn-link { margin-left: 0; } +} + +@media (min-width: 641px) { + .page { flex-direction: row; } + + .sidebar { + height: 100vh; + position: sticky; + top: 0; + width: 250px; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .main > div { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} \ No newline at end of file diff --git a/ErsatzTV/Shared/MediaItemTable.razor b/ErsatzTV/Shared/MediaItemTable.razor new file mode 100644 index 000000000..e4a804a31 --- /dev/null +++ b/ErsatzTV/Shared/MediaItemTable.razor @@ -0,0 +1,66 @@ +@using ErsatzTV.Application.MediaItems +@using ErsatzTV.Application.MediaItems.Queries +@inject IMediator Mediator + + + + Media Items + + + + + + Source + Title + Count + Duration + + + @context.Source + @context.Title + @context.Count + @context.Duration + + + + + + +@code { + + [Parameter] + public MediaType MediaType { get; set; } + + private IEnumerable _pagedData; + private MudTable _table; + + private int _totalItems; + private string _searchString; + + private async Task> ServerReload(TableState state) + { + List aggregateData = + await Mediator.Send(new GetAggregateMediaItems(MediaType, _searchString)); + + _totalItems = aggregateData.Count; + + _pagedData = aggregateData.Skip(state.Page * state.PageSize).Take(state.PageSize); + return new TableData { TotalItems = _totalItems, Items = _pagedData }; + } + + private void OnSearch(string text) + { + _searchString = text; + _table.ReloadServerData(); + } + + private class MediaItemAggregate + { + public string Source { get; set; } + public string Title { get; set; } + public int Count { get; set; } + public string Duration { get; set; } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Shared/PlexMediaSources.razor b/ErsatzTV/Shared/PlexMediaSources.razor new file mode 100644 index 000000000..633dd6088 --- /dev/null +++ b/ErsatzTV/Shared/PlexMediaSources.razor @@ -0,0 +1,90 @@ +@using ErsatzTV.Application.MediaSources +@using ErsatzTV.Application.MediaSources.Commands +@using ErsatzTV.Application.MediaSources.Queries +@inject IDialogService Dialog +@inject IMediator Mediator +@inject ISnackbar Snackbar +@inject ILogger Logger +@inject IJSRuntime JsRuntime + + + + Plex Media Sources + + + + + + + + Name + Address + + + + @context.Name + @context.Address + + + + Edit Libraries + + @* *@ + @* Edit Path Replacements *@ + @* *@ + + + + + + Add Plex Media Source + + +@code { + private List _mediaSources; + + protected override async Task OnParametersSetAsync() => await LoadMediaSources(); + + private async Task LoadMediaSources() => + _mediaSources = await Mediator.Send(new GetAllPlexMediaSources()); + + // private async Task DeleteMediaSource(PlexMediaSourceViewModel mediaSource) + // { + // int count = await Mediator.Send(new CountMediaItemsById(mediaSource.Id)); + // + // var parameters = new DialogParameters + // { + // { "EntityType", "media source" }, + // { "EntityName", mediaSource.Name }, + // { "DetailText", $"This media source contains {count} media items." }, + // { "DetailHighlight", count.ToString() } + // }; + // var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + // + // IDialogReference dialog = Dialog.Show("Delete Media Source", parameters, options); + // DialogResult result = await dialog.Result; + // if (!result.Cancelled) + // { + // await Mediator.Send(new DeleteLocalMediaSource(mediaSource.Id)); + // await LoadMediaSources(); + // } + // } + + // edit media source + // - manage libraries to include in smart collections + // - manage list of path replacements + + private async Task AddPlexMediaSource() + { + Either maybeUrl = await Mediator.Send(new StartPlexPinFlow()); + await maybeUrl.Match( + async url => await JsRuntime.InvokeAsync("open", new object[] { url, "_blank" }), + error => + { + Snackbar.Add(error.Value, Severity.Error); + Logger.LogError("Unexpected error generating plex auth app url: {Error}", error.Value); + return Task.CompletedTask; + }); + } + +} \ No newline at end of file diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs new file mode 100644 index 000000000..deeaca41c --- /dev/null +++ b/ErsatzTV/Startup.cs @@ -0,0 +1,184 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Channels.Queries; +using ErsatzTV.Core; +using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Metadata; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Formatters; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Data.Repositories; +using ErsatzTV.Infrastructure.Plex; +using ErsatzTV.Serialization; +using ErsatzTV.Services; +using FluentValidation.AspNetCore; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; +using MudBlazor; +using MudBlazor.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Refit; +using Serilog; + +namespace ErsatzTV +{ + public class Startup + { + public Startup(IConfiguration configuration) => Configuration = configuration; + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers( + options => + { + options.OutputFormatters.Insert(0, new ConcatPlaylistOutputFormatter()); + options.OutputFormatters.Insert(0, new ChannelPlaylistOutputFormatter()); + options.OutputFormatters.Insert(0, new ChannelGuideOutputFormatter()); + options.OutputFormatters.Insert(0, new DeviceXmlOutputFormatter()); + options.OutputFormatters.Insert(0, new HdhrJsonOutputFormatter()); + }) + .AddNewtonsoftJson( + opt => + { + opt.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; + opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; + opt.SerializerSettings.ContractResolver = new CustomContractResolver(); + opt.SerializerSettings.Converters.Add(new StringEnumConverter()); + }) + .AddFluentValidation( + options => + { + options.RegisterValidatorsFromAssemblyContaining(); + options.ImplicitlyValidateChildProperties = true; + }); + + services.AddSwaggerGen( + c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "ErsatzTV API", Version = "v1" }); }); + services.AddSwaggerGenNewtonsoftSupport(); + + services.AddRazorPages(); + services.AddServerSideBlazor(); + + services.AddMudBlazorDialog() + .AddMudBlazorSnackbar() + .AddMudBlazorResizeListener(); + + if (!Directory.Exists(FileSystemLayout.AppDataFolder)) + { + Directory.CreateDirectory(FileSystemLayout.AppDataFolder); + } + + Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath); + + // until we add a setting for a file-specific scheme://host:port to access + // stream urls contained in this file, it doesn't make sense to do + // for now, continue to use scheme and host from incoming requests + // string xmltvPath = Path.Combine(appDataFolder, "xmltv.xml"); + // Log.Logger.Information("XMLTV is at {XmltvPath}", xmltvPath); + + services.AddDbContextFactory( + options => + { + options.UseSqlite( + $"Data Source={FileSystemLayout.DatabasePath}", + o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); + + if (Debugger.IsAttached) + { + options.EnableSensitiveDataLogging(); + } + }); + + services.AddDbContext( + options => options.UseSqlite( + $"Data Source={FileSystemLayout.DatabasePath}", + o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery))); + + services.AddMediatR(typeof(GetAllChannels).Assembly); + + services.AddRefitClient() + .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://plex.tv/api/v2")); + + CustomServices(services); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + // app.UseSerilogRequestLogging(); + + app.UseSwagger(); + app.UseSwaggerUI( + c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "ErsatzTV API"); }); + + app.UseStaticFiles(); + + app.UseRouting(); + + app.UseEndpoints( + endpoints => + { + endpoints.MapControllers(); + endpoints.MapBlazorHub(); + endpoints.MapFallbackToPage("/_Host"); + }); + } + + private void CustomServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // TODO: does this need to be singleton? + services.AddSingleton(); + AddChannel(services); + AddChannel(services); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + } + + private void AddChannel(IServiceCollection services) + { + services.AddSingleton( + Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true })); + services.AddSingleton( + provider => provider.GetRequiredService>().Reader); + services.AddSingleton( + provider => provider.GetRequiredService>().Writer); + } + } +} diff --git a/ErsatzTV/Validators/ChannelEditViewModelValidator.cs b/ErsatzTV/Validators/ChannelEditViewModelValidator.cs new file mode 100644 index 000000000..621f50efa --- /dev/null +++ b/ErsatzTV/Validators/ChannelEditViewModelValidator.cs @@ -0,0 +1,15 @@ +using ErsatzTV.ViewModels; +using FluentValidation; + +namespace ErsatzTV.Validators +{ + public class ChannelEditViewModelValidator : AbstractValidator + { + public ChannelEditViewModelValidator() + { + RuleFor(x => x.Number).GreaterThan(0); + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.FFmpegProfileId).GreaterThan(0); + } + } +} diff --git a/ErsatzTV/Validators/FFmpegProfileEditViewModelValidator.cs b/ErsatzTV/Validators/FFmpegProfileEditViewModelValidator.cs new file mode 100644 index 000000000..dc6109351 --- /dev/null +++ b/ErsatzTV/Validators/FFmpegProfileEditViewModelValidator.cs @@ -0,0 +1,28 @@ +using ErsatzTV.ViewModels; +using FluentValidation; + +namespace ErsatzTV.Validators +{ + public class FFmpegProfileEditViewModelValidator : AbstractValidator + { + public FFmpegProfileEditViewModelValidator() + { + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.ThreadCount).GreaterThan(0); + + When( + x => x.Transcode, + () => + { + RuleFor(x => x.VideoCodec).NotEmpty(); + RuleFor(x => x.VideoBitrate).GreaterThan(0); + RuleFor(x => x.VideoBufferSize).GreaterThan(0); + + RuleFor(x => x.AudioCodec).NotEmpty(); + RuleFor(x => x.AudioBitrate).GreaterThan(0); + RuleFor(x => x.AudioVolume).GreaterThanOrEqualTo(0); + RuleFor(x => x.AudioChannels).GreaterThan(0); + }); + } + } +} diff --git a/ErsatzTV/Validators/LocalMediaSourceEditViewModelValidator.cs b/ErsatzTV/Validators/LocalMediaSourceEditViewModelValidator.cs new file mode 100644 index 000000000..8957bb5e9 --- /dev/null +++ b/ErsatzTV/Validators/LocalMediaSourceEditViewModelValidator.cs @@ -0,0 +1,15 @@ +using System.IO; +using ErsatzTV.ViewModels; +using FluentValidation; + +namespace ErsatzTV.Validators +{ + public class LocalMediaSourceEditViewModelValidator : AbstractValidator + { + public LocalMediaSourceEditViewModelValidator() + { + RuleFor(x => x.Folder).NotEmpty(); + RuleFor(x => x.Folder).Must(Directory.Exists).WithMessage("Folder must exist on filesystem"); + } + } +} diff --git a/ErsatzTV/Validators/ProgramScheduleEditViewModelValidator.cs b/ErsatzTV/Validators/ProgramScheduleEditViewModelValidator.cs new file mode 100644 index 000000000..40d36f00e --- /dev/null +++ b/ErsatzTV/Validators/ProgramScheduleEditViewModelValidator.cs @@ -0,0 +1,10 @@ +using ErsatzTV.ViewModels; +using FluentValidation; + +namespace ErsatzTV.Validators +{ + public class ProgramScheduleEditViewModelValidator : AbstractValidator + { + public ProgramScheduleEditViewModelValidator() => RuleFor(vm => vm.Name).NotEmpty(); + } +} diff --git a/ErsatzTV/Validators/SimpleMediaCollectionEditViewModelValidator.cs b/ErsatzTV/Validators/SimpleMediaCollectionEditViewModelValidator.cs new file mode 100644 index 000000000..33397403c --- /dev/null +++ b/ErsatzTV/Validators/SimpleMediaCollectionEditViewModelValidator.cs @@ -0,0 +1,10 @@ +using ErsatzTV.ViewModels; +using FluentValidation; + +namespace ErsatzTV.Validators +{ + public class SimpleMediaCollectionEditViewModelValidator : AbstractValidator + { + public SimpleMediaCollectionEditViewModelValidator() => RuleFor(c => c.Name).NotEmpty(); + } +} diff --git a/ErsatzTV/ViewModels/ChannelEditViewModel.cs b/ErsatzTV/ViewModels/ChannelEditViewModel.cs new file mode 100644 index 000000000..88c020313 --- /dev/null +++ b/ErsatzTV/ViewModels/ChannelEditViewModel.cs @@ -0,0 +1,32 @@ +using ErsatzTV.Application.Channels.Commands; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.ViewModels +{ + public class ChannelEditViewModel + { + public int Id { get; set; } + public string Name { get; set; } + public int Number { get; set; } + public int FFmpegProfileId { get; set; } + public string Logo { get; set; } + public StreamingMode StreamingMode { get; set; } + + public UpdateChannel ToUpdate() => + new( + Id, + Name, + Number, + FFmpegProfileId, + Logo, + StreamingMode); + + public CreateChannel ToCreate() => + new( + Name, + Number, + FFmpegProfileId, + Logo, + StreamingMode); + } +} diff --git a/ErsatzTV/ViewModels/FFmpegProfileEditViewModel.cs b/ErsatzTV/ViewModels/FFmpegProfileEditViewModel.cs new file mode 100644 index 000000000..7fcaba813 --- /dev/null +++ b/ErsatzTV/ViewModels/FFmpegProfileEditViewModel.cs @@ -0,0 +1,97 @@ +using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Application.FFmpegProfiles.Commands; +using ErsatzTV.Application.Resolutions; + +namespace ErsatzTV.ViewModels +{ + public class FFmpegProfileEditViewModel + { + public FFmpegProfileEditViewModel() + { + } + + public FFmpegProfileEditViewModel(FFmpegProfileViewModel viewModel) + { + AudioBitrate = viewModel.AudioBitrate; + AudioBufferSize = viewModel.AudioBufferSize; + AudioChannels = viewModel.AudioChannels; + AudioCodec = viewModel.AudioCodec; + AudioSampleRate = viewModel.AudioSampleRate; + AudioVolume = viewModel.AudioVolume; + Id = viewModel.Id; + Name = viewModel.Name; + NormalizeAudio = viewModel.NormalizeAudio; + NormalizeAudioCodec = viewModel.NormalizeAudioCodec; + NormalizeResolution = viewModel.NormalizeResolution; + NormalizeVideoCodec = viewModel.NormalizeVideoCodec; + Resolution = viewModel.Resolution; + ThreadCount = viewModel.ThreadCount; + Transcode = viewModel.Transcode; + VideoBitrate = viewModel.VideoBitrate; + VideoBufferSize = viewModel.VideoBufferSize; + VideoCodec = viewModel.VideoCodec; + } + + public int AudioBitrate { get; set; } + public int AudioBufferSize { get; set; } + public int AudioChannels { get; set; } + public string AudioCodec { get; set; } + public int AudioSampleRate { get; set; } + public int AudioVolume { get; set; } + public int Id { get; set; } + public string Name { get; set; } + public bool NormalizeAudio { get; set; } + public bool NormalizeAudioCodec { get; set; } + public bool NormalizeResolution { get; set; } + public bool NormalizeVideoCodec { get; set; } + public ResolutionViewModel Resolution { get; set; } + public int ThreadCount { get; set; } + public bool Transcode { get; set; } + public int VideoBitrate { get; set; } + public int VideoBufferSize { get; set; } + public string VideoCodec { get; set; } + + public CreateFFmpegProfile ToCreate() => + new( + Name, + ThreadCount, + Transcode, + Resolution.Id, + NormalizeResolution, + VideoCodec, + NormalizeAudioCodec, + VideoBitrate, + VideoBufferSize, + AudioCodec, + NormalizeAudioCodec, + AudioBitrate, + AudioBufferSize, + AudioVolume, + AudioChannels, + AudioSampleRate, + NormalizeAudio + ); + + public UpdateFFmpegProfile ToUpdate() => + new( + Id, + Name, + ThreadCount, + Transcode, + Resolution.Id, + NormalizeResolution, + VideoCodec, + NormalizeAudioCodec, + VideoBitrate, + VideoBufferSize, + AudioCodec, + NormalizeAudioCodec, + AudioBitrate, + AudioBufferSize, + AudioVolume, + AudioChannels, + AudioSampleRate, + NormalizeAudio + ); + } +} diff --git a/ErsatzTV/ViewModels/LocalMediaSourceEditViewModel.cs b/ErsatzTV/ViewModels/LocalMediaSourceEditViewModel.cs new file mode 100644 index 000000000..97a6fe572 --- /dev/null +++ b/ErsatzTV/ViewModels/LocalMediaSourceEditViewModel.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.ViewModels +{ + public class LocalMediaSourceEditViewModel + { + public MediaType MediaType { get; set; } + public string Folder { get; set; } + } +} diff --git a/ErsatzTV/ViewModels/ProgramScheduleEditViewModel.cs b/ErsatzTV/ViewModels/ProgramScheduleEditViewModel.cs new file mode 100644 index 000000000..c11ec8cce --- /dev/null +++ b/ErsatzTV/ViewModels/ProgramScheduleEditViewModel.cs @@ -0,0 +1,14 @@ +using ErsatzTV.Application.ProgramSchedules.Commands; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.ViewModels +{ + public class ProgramScheduleEditViewModel + { + public int Id { get; set; } + public string Name { get; set; } + public PlaybackOrder MediaCollectionPlaybackOrder { get; set; } + public UpdateProgramSchedule ToUpdate() => new(Id, Name, MediaCollectionPlaybackOrder); + public CreateProgramSchedule ToCreate() => new(Name, MediaCollectionPlaybackOrder); + } +} diff --git a/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs b/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs new file mode 100644 index 000000000..03a777105 --- /dev/null +++ b/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs @@ -0,0 +1,16 @@ +using System; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.ViewModels +{ + public class ProgramScheduleItemEditViewModel + { + public int Id { get; set; } + public int Index { get; set; } + public StartType StartType { get; set; } + public TimeSpan? StartTime { get; set; } + public PlayoutMode PlayoutMode { get; set; } + public string MediaCollectionName { get; set; } + } +} diff --git a/ErsatzTV/ViewModels/ProgramScheduleItemsEditViewModel.cs b/ErsatzTV/ViewModels/ProgramScheduleItemsEditViewModel.cs new file mode 100644 index 000000000..4e69bd012 --- /dev/null +++ b/ErsatzTV/ViewModels/ProgramScheduleItemsEditViewModel.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace ErsatzTV.ViewModels +{ + public class ProgramScheduleItemsEditViewModel + { + public string Name { get; set; } + public List Items { get; set; } + } +} diff --git a/ErsatzTV/ViewModels/SimpleMediaCollectionEditViewModel.cs b/ErsatzTV/ViewModels/SimpleMediaCollectionEditViewModel.cs new file mode 100644 index 000000000..d90aa5bd9 --- /dev/null +++ b/ErsatzTV/ViewModels/SimpleMediaCollectionEditViewModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.ViewModels +{ + public class SimpleMediaCollectionEditViewModel + { + public int Id { get; set; } + public string Name { get; set; } + } +} diff --git a/ErsatzTV/_Imports.razor b/ErsatzTV/_Imports.razor new file mode 100644 index 000000000..a94e44b42 --- /dev/null +++ b/ErsatzTV/_Imports.razor @@ -0,0 +1,26 @@ +@using System.IO +@using System.Net.Http +@using System.Net.Http.Json +@using System.Threading.Channels +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.Data.Sqlite +@using Microsoft.EntityFrameworkCore +@using Microsoft.Extensions.Logging +@using Microsoft.JSInterop +@using LanguageExt +@using MediatR +@using MudBlazor +@using MudBlazor.Dialog +@using ErsatzTV +@using ErsatzTV.Core +@using ErsatzTV.Core.Domain +@using ErsatzTV.Infrastructure.Data +@using ErsatzTV.Models +@using ErsatzTV.Models.UI +@using ErsatzTV.Shared +@using ErsatzTV.ViewModels \ No newline at end of file diff --git a/ErsatzTV/appsettings.Development.json b/ErsatzTV/appsettings.Development.json new file mode 100644 index 000000000..bdb741b42 --- /dev/null +++ b/ErsatzTV/appsettings.Development.json @@ -0,0 +1,23 @@ +{ + "Serilog": { + "Using": [ + "Serilog.Sinks.Console" + ], + "MinimumLevel": { + "Default": "Debug" + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + } +} \ No newline at end of file diff --git a/ErsatzTV/appsettings.json b/ErsatzTV/appsettings.json new file mode 100644 index 000000000..d753b32e5 --- /dev/null +++ b/ErsatzTV/appsettings.json @@ -0,0 +1,28 @@ +{ + "Serilog": { + "Using": [ + "Serilog.Sinks.Console" + ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System.Net.Http.HttpClient": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/ErsatzTV/wwwroot/css/bootstrap/bootstrap.min.css b/ErsatzTV/wwwroot/css/bootstrap/bootstrap.min.css new file mode 100644 index 000000000..92e3fe871 --- /dev/null +++ b/ErsatzTV/wwwroot/css/bootstrap/bootstrap.min.css @@ -0,0 +1,7 @@ +/*! + * Bootstrap v4.3.1 (https://getbootstrap.com/) + * Copyright 2011-2019 The Bootstrap Authors + * Copyright 2011-2019 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#6c757d;--gray-dark:#343a40;--primary:#007bff;--secondary:#6c757d;--success:#28a745;--info:#17a2b8;--warning:#ffc107;--danger:#dc3545;--light:#f8f9fa;--dark:#343a40;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,.1)}.small,small{font-size:80%;font-weight:400}.mark,mark{padding:.2em;background-color:#fcf8e3}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote-footer{display:block;font-size:80%;color:#6c757d}.blockquote-footer::before{content:"\2014\00A0"}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:90%;color:#6c757d}code{font-size:87.5%;color:#e83e8c;word-break:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:87.5%;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre{display:block;font-size:87.5%;color:#212529}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container{max-width:1140px}}.container-fluid{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-right:0;padding-left:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-ms-flex-order:-1;order:-1}.order-last{-ms-flex-order:13;order:13}.order-0{-ms-flex-order:0;order:0}.order-1{-ms-flex-order:1;order:1}.order-2{-ms-flex-order:2;order:2}.order-3{-ms-flex-order:3;order:3}.order-4{-ms-flex-order:4;order:4}.order-5{-ms-flex-order:5;order:5}.order-6{-ms-flex-order:6;order:6}.order-7{-ms-flex-order:7;order:7}.order-8{-ms-flex-order:8;order:8}.order-9{-ms-flex-order:9;order:9}.order-10{-ms-flex-order:10;order:10}.order-11{-ms-flex-order:11;order:11}.order-12{-ms-flex-order:12;order:12}.offset-1{margin-left:8.333333%}.offset-2{margin-left:16.666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.333333%}.offset-5{margin-left:41.666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.333333%}.offset-8{margin-left:66.666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.333333%}.offset-11{margin-left:91.666667%}@media (min-width:576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-sm-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-sm-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-sm-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-sm-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-sm-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-sm-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-ms-flex-order:-1;order:-1}.order-sm-last{-ms-flex-order:13;order:13}.order-sm-0{-ms-flex-order:0;order:0}.order-sm-1{-ms-flex-order:1;order:1}.order-sm-2{-ms-flex-order:2;order:2}.order-sm-3{-ms-flex-order:3;order:3}.order-sm-4{-ms-flex-order:4;order:4}.order-sm-5{-ms-flex-order:5;order:5}.order-sm-6{-ms-flex-order:6;order:6}.order-sm-7{-ms-flex-order:7;order:7}.order-sm-8{-ms-flex-order:8;order:8}.order-sm-9{-ms-flex-order:9;order:9}.order-sm-10{-ms-flex-order:10;order:10}.order-sm-11{-ms-flex-order:11;order:11}.order-sm-12{-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.333333%}.offset-sm-2{margin-left:16.666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.333333%}.offset-sm-5{margin-left:41.666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.333333%}.offset-sm-8{margin-left:66.666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.333333%}.offset-sm-11{margin-left:91.666667%}}@media (min-width:768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-md-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-md-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-md-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-md-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-md-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-md-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-md-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-ms-flex-order:-1;order:-1}.order-md-last{-ms-flex-order:13;order:13}.order-md-0{-ms-flex-order:0;order:0}.order-md-1{-ms-flex-order:1;order:1}.order-md-2{-ms-flex-order:2;order:2}.order-md-3{-ms-flex-order:3;order:3}.order-md-4{-ms-flex-order:4;order:4}.order-md-5{-ms-flex-order:5;order:5}.order-md-6{-ms-flex-order:6;order:6}.order-md-7{-ms-flex-order:7;order:7}.order-md-8{-ms-flex-order:8;order:8}.order-md-9{-ms-flex-order:9;order:9}.order-md-10{-ms-flex-order:10;order:10}.order-md-11{-ms-flex-order:11;order:11}.order-md-12{-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.333333%}.offset-md-2{margin-left:16.666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.333333%}.offset-md-5{margin-left:41.666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.333333%}.offset-md-8{margin-left:66.666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.333333%}.offset-md-11{margin-left:91.666667%}}@media (min-width:992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-lg-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-lg-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-lg-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-lg-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-lg-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-lg-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-lg-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-lg-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-ms-flex-order:-1;order:-1}.order-lg-last{-ms-flex-order:13;order:13}.order-lg-0{-ms-flex-order:0;order:0}.order-lg-1{-ms-flex-order:1;order:1}.order-lg-2{-ms-flex-order:2;order:2}.order-lg-3{-ms-flex-order:3;order:3}.order-lg-4{-ms-flex-order:4;order:4}.order-lg-5{-ms-flex-order:5;order:5}.order-lg-6{-ms-flex-order:6;order:6}.order-lg-7{-ms-flex-order:7;order:7}.order-lg-8{-ms-flex-order:8;order:8}.order-lg-9{-ms-flex-order:9;order:9}.order-lg-10{-ms-flex-order:10;order:10}.order-lg-11{-ms-flex-order:11;order:11}.order-lg-12{-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.333333%}.offset-lg-2{margin-left:16.666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.333333%}.offset-lg-5{margin-left:41.666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.333333%}.offset-lg-8{margin-left:66.666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.333333%}.offset-lg-11{margin-left:91.666667%}}@media (min-width:1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-xl-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-xl-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-xl-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-xl-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-xl-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-xl-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-xl-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-xl-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-ms-flex-order:-1;order:-1}.order-xl-last{-ms-flex-order:13;order:13}.order-xl-0{-ms-flex-order:0;order:0}.order-xl-1{-ms-flex-order:1;order:1}.order-xl-2{-ms-flex-order:2;order:2}.order-xl-3{-ms-flex-order:3;order:3}.order-xl-4{-ms-flex-order:4;order:4}.order-xl-5{-ms-flex-order:5;order:5}.order-xl-6{-ms-flex-order:6;order:6}.order-xl-7{-ms-flex-order:7;order:7}.order-xl-8{-ms-flex-order:8;order:8}.order-xl-9{-ms-flex-order:9;order:9}.order-xl-10{-ms-flex-order:10;order:10}.order-xl-11{-ms-flex-order:11;order:11}.order-xl-12{-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.333333%}.offset-xl-2{margin-left:16.666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.333333%}.offset-xl-5{margin-left:41.666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.333333%}.offset-xl-8{margin-left:66.666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.333333%}.offset-xl-11{margin-left:91.666667%}}.table{width:100%;margin-bottom:1rem;color:#212529}.table td,.table th{padding:.75rem;vertical-align:top;border-top:1px solid #dee2e6}.table thead th{vertical-align:bottom;border-bottom:2px solid #dee2e6}.table tbody+tbody{border-top:2px solid #dee2e6}.table-sm td,.table-sm th{padding:.3rem}.table-bordered{border:1px solid #dee2e6}.table-bordered td,.table-bordered th{border:1px solid #dee2e6}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-borderless tbody+tbody,.table-borderless td,.table-borderless th,.table-borderless thead th{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,0,.05)}.table-hover tbody tr:hover{color:#212529;background-color:rgba(0,0,0,.075)}.table-primary,.table-primary>td,.table-primary>th{background-color:#b8daff}.table-primary tbody+tbody,.table-primary td,.table-primary th,.table-primary thead th{border-color:#7abaff}.table-hover .table-primary:hover{background-color:#9fcdff}.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#9fcdff}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#d6d8db}.table-secondary tbody+tbody,.table-secondary td,.table-secondary th,.table-secondary thead th{border-color:#b3b7bb}.table-hover .table-secondary:hover{background-color:#c8cbcf}.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#c8cbcf}.table-success,.table-success>td,.table-success>th{background-color:#c3e6cb}.table-success tbody+tbody,.table-success td,.table-success th,.table-success thead th{border-color:#8fd19e}.table-hover .table-success:hover{background-color:#b1dfbb}.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#b1dfbb}.table-info,.table-info>td,.table-info>th{background-color:#bee5eb}.table-info tbody+tbody,.table-info td,.table-info th,.table-info thead th{border-color:#86cfda}.table-hover .table-info:hover{background-color:#abdde5}.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#abdde5}.table-warning,.table-warning>td,.table-warning>th{background-color:#ffeeba}.table-warning tbody+tbody,.table-warning td,.table-warning th,.table-warning thead th{border-color:#ffdf7e}.table-hover .table-warning:hover{background-color:#ffe8a1}.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#ffe8a1}.table-danger,.table-danger>td,.table-danger>th{background-color:#f5c6cb}.table-danger tbody+tbody,.table-danger td,.table-danger th,.table-danger thead th{border-color:#ed969e}.table-hover .table-danger:hover{background-color:#f1b0b7}.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f1b0b7}.table-light,.table-light>td,.table-light>th{background-color:#fdfdfe}.table-light tbody+tbody,.table-light td,.table-light th,.table-light thead th{border-color:#fbfcfc}.table-hover .table-light:hover{background-color:#ececf6}.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#ececf6}.table-dark,.table-dark>td,.table-dark>th{background-color:#c6c8ca}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#95999c}.table-hover .table-dark:hover{background-color:#b9bbbe}.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b9bbbe}.table-active,.table-active>td,.table-active>th{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(0,0,0,.075)}.table .thead-dark th{color:#fff;background-color:#343a40;border-color:#454d55}.table .thead-light th{color:#495057;background-color:#e9ecef;border-color:#dee2e6}.table-dark{color:#fff;background-color:#343a40}.table-dark td,.table-dark th,.table-dark thead th{border-color:#454d55}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,.05)}.table-dark.table-hover tbody tr:hover{color:#fff;background-color:rgba(255,255,255,.075)}@media (max-width:575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-md>.table-bordered{border:0}}@media (max-width:991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive>.table-bordered{border:0}.form-control{display:block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:focus{color:#495057;background-color:#fff;border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.form-control::-webkit-input-placeholder{color:#6c757d;opacity:1}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control:-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}select.form-control:focus::-ms-value{color:#495057;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem;line-height:1.5}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding-top:.375rem;padding-bottom:.375rem;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.form-control-lg{height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}select.form-control[multiple],select.form-control[size]{height:auto}textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*=col-]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block;padding-left:1.25rem}.form-check-input{position:absolute;margin-top:.3rem;margin-left:-1.25rem}.form-check-input:disabled~.form-check-label{color:#6c757d}.form-check-label{margin-bottom:0}.form-check-inline{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;align-items:center;padding-left:0;margin-right:.75rem}.form-check-inline .form-check-input{position:static;margin-top:0;margin-right:.3125rem;margin-left:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#28a745}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(40,167,69,.9);border-radius:.25rem}.form-control.is-valid,.was-validated .form-control:valid{border-color:#28a745;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:center right calc(.375em + .1875rem);background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.form-control.is-valid~.valid-feedback,.form-control.is-valid~.valid-tooltip,.was-validated .form-control:valid~.valid-feedback,.was-validated .form-control:valid~.valid-tooltip{display:block}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-valid,.was-validated .custom-select:valid{border-color:#28a745;padding-right:calc((1em + .75rem) * 3 / 4 + 1.75rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.custom-select.is-valid:focus,.was-validated .custom-select:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.custom-select.is-valid~.valid-feedback,.custom-select.is-valid~.valid-tooltip,.was-validated .custom-select:valid~.valid-feedback,.was-validated .custom-select:valid~.valid-tooltip{display:block}.form-control-file.is-valid~.valid-feedback,.form-control-file.is-valid~.valid-tooltip,.was-validated .form-control-file:valid~.valid-feedback,.was-validated .form-control-file:valid~.valid-tooltip{display:block}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#28a745}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#28a745}.custom-control-input.is-valid~.custom-control-label::before,.was-validated .custom-control-input:valid~.custom-control-label::before{border-color:#28a745}.custom-control-input.is-valid~.valid-feedback,.custom-control-input.is-valid~.valid-tooltip,.was-validated .custom-control-input:valid~.valid-feedback,.was-validated .custom-control-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid:checked~.custom-control-label::before,.was-validated .custom-control-input:valid:checked~.custom-control-label::before{border-color:#34ce57;background-color:#34ce57}.custom-control-input.is-valid:focus~.custom-control-label::before,.was-validated .custom-control-input:valid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.custom-control-input.is-valid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:valid:focus:not(:checked)~.custom-control-label::before{border-color:#28a745}.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#28a745}.custom-file-input.is-valid~.valid-feedback,.custom-file-input.is-valid~.valid-tooltip,.was-validated .custom-file-input:valid~.valid-feedback,.was-validated .custom-file-input:valid~.valid-tooltip{display:block}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23dc3545' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23dc3545' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E");background-repeat:no-repeat;background-position:center right calc(.375em + .1875rem);background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-control.is-invalid~.invalid-feedback,.form-control.is-invalid~.invalid-tooltip,.was-validated .form-control:invalid~.invalid-feedback,.was-validated .form-control:invalid~.invalid-tooltip{display:block}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-invalid,.was-validated .custom-select:invalid{border-color:#dc3545;padding-right:calc((1em + .75rem) * 3 / 4 + 1.75rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23dc3545' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23dc3545' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.custom-select.is-invalid:focus,.was-validated .custom-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.custom-select.is-invalid~.invalid-feedback,.custom-select.is-invalid~.invalid-tooltip,.was-validated .custom-select:invalid~.invalid-feedback,.was-validated .custom-select:invalid~.invalid-tooltip{display:block}.form-control-file.is-invalid~.invalid-feedback,.form-control-file.is-invalid~.invalid-tooltip,.was-validated .form-control-file:invalid~.invalid-feedback,.was-validated .form-control-file:invalid~.invalid-tooltip{display:block}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#dc3545}.custom-control-input.is-invalid~.custom-control-label::before,.was-validated .custom-control-input:invalid~.custom-control-label::before{border-color:#dc3545}.custom-control-input.is-invalid~.invalid-feedback,.custom-control-input.is-invalid~.invalid-tooltip,.was-validated .custom-control-input:invalid~.invalid-feedback,.was-validated .custom-control-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid:checked~.custom-control-label::before,.was-validated .custom-control-input:invalid:checked~.custom-control-label::before{border-color:#e4606d;background-color:#e4606d}.custom-control-input.is-invalid:focus~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.custom-control-input.is-invalid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus:not(:checked)~.custom-control-label::before{border-color:#dc3545}.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#dc3545}.custom-file-input.is-invalid~.invalid-feedback,.custom-file-input.is-invalid~.invalid-tooltip,.was-validated .custom-file-input:invalid~.invalid-feedback,.was-validated .custom-file-input:invalid~.invalid-tooltip{display:block}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-inline{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width:576px){.form-inline label{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;margin-bottom:0}.form-inline .form-group{display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .custom-select,.form-inline .input-group{width:auto}.form-inline .form-check{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:auto;padding-left:0}.form-inline .form-check-input{position:relative;-ms-flex-negative:0;flex-shrink:0;margin-top:0;margin-right:.25rem;margin-left:0}.form-inline .custom-control{-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{display:inline-block;font-weight:400;color:#212529;text-align:center;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;line-height:1.5;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529;text-decoration:none}.btn.focus,.btn:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.btn.disabled,.btn:disabled{opacity:.65}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:hover{color:#fff;background-color:#0069d9;border-color:#0062cc}.btn-primary.focus,.btn-primary:focus{box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0062cc;border-color:#005cbf}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5a6268;border-color:#545b62}.btn-secondary.focus,.btn-secondary:focus{box-shadow:0 0 0 .2rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#545b62;border-color:#4e555b}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(130,138,145,.5)}.btn-success{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:hover{color:#fff;background-color:#218838;border-color:#1e7e34}.btn-success.focus,.btn-success:focus{box-shadow:0 0 0 .2rem rgba(72,180,97,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#1e7e34;border-color:#1c7430}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(72,180,97,.5)}.btn-info{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:hover{color:#fff;background-color:#138496;border-color:#117a8b}.btn-info.focus,.btn-info:focus{box-shadow:0 0 0 .2rem rgba(58,176,195,.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#117a8b;border-color:#10707f}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(58,176,195,.5)}.btn-warning{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#212529;background-color:#e0a800;border-color:#d39e00}.btn-warning.focus,.btn-warning:focus{box-shadow:0 0 0 .2rem rgba(222,170,12,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{color:#212529;background-color:#d39e00;border-color:#c69500}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(222,170,12,.5)}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#c82333;border-color:#bd2130}.btn-danger.focus,.btn-danger:focus{box-shadow:0 0 0 .2rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#bd2130;border-color:#b21f2d}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(225,83,97,.5)}.btn-light{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#212529;background-color:#e2e6ea;border-color:#dae0e5}.btn-light.focus,.btn-light:focus{box-shadow:0 0 0 .2rem rgba(216,217,219,.5)}.btn-light.disabled,.btn-light:disabled{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{color:#212529;background-color:#dae0e5;border-color:#d3d9df}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(216,217,219,.5)}.btn-dark{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:hover{color:#fff;background-color:#23272b;border-color:#1d2124}.btn-dark.focus,.btn-dark:focus{box-shadow:0 0 0 .2rem rgba(82,88,93,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1d2124;border-color:#171a1d}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(82,88,93,.5)}.btn-outline-primary{color:#007bff;border-color:#007bff}.btn-outline-primary:hover{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#007bff;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-success{color:#28a745;border-color:#28a745}.btn-outline-success:hover{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#28a745;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-info{color:#17a2b8;border-color:#17a2b8}.btn-outline-info:hover{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#17a2b8;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-dark{color:#343a40;border-color:#343a40}.btn-outline-dark:hover{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#343a40;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-link{font-weight:400;color:#007bff;text-decoration:none}.btn-link:hover{color:#0056b3;text-decoration:underline}.btn-link.focus,.btn-link:focus{text-decoration:underline;box-shadow:none}.btn-link.disabled,.btn-link:disabled{color:#6c757d;pointer-events:none}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{position:relative;height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.dropdown,.dropleft,.dropright,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu-left{right:auto;left:0}.dropdown-menu-right{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-left{right:auto;left:0}.dropdown-menu-sm-right{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-left{right:auto;left:0}.dropdown-menu-md-right{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-left{right:auto;left:0}.dropdown-menu-lg-right{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-left{right:auto;left:0}.dropdown-menu-xl-right{right:0;left:auto}}.dropup .dropdown-menu{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-menu{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropright .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropright .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-toggle::after{vertical-align:0}.dropleft .dropdown-menu{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropleft .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropleft .dropdown-toggle::after{display:none}.dropleft .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropleft .dropdown-toggle:empty::after{margin-left:0}.dropleft .dropdown-toggle::before{vertical-align:0}.dropdown-menu[x-placement^=bottom],.dropdown-menu[x-placement^=left],.dropdown-menu[x-placement^=right],.dropdown-menu[x-placement^=top]{right:auto;bottom:auto}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid #e9ecef}.dropdown-item{display:block;width:100%;padding:.25rem 1.5rem;clear:both;font-weight:400;color:#212529;text-align:inherit;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#16181b;text-decoration:none;background-color:#f8f9fa}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#007bff}.dropdown-item.disabled,.dropdown-item:disabled{color:#6c757d;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1.5rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1.5rem;color:#212529}.btn-group,.btn-group-vertical{position:relative;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;-ms-flex:1 1 auto;flex:1 1 auto}.btn-group-vertical>.btn:hover,.btn-group>.btn:hover{z-index:1}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus{z-index:1}.btn-toolbar{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropright .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropleft .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{-ms-flex-direction:column;flex-direction:column;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:center;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control,.input-group>.form-control-plaintext{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;width:1%;margin-bottom:0}.input-group>.custom-file+.custom-file,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.form-control,.input-group>.custom-select+.custom-file,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.form-control,.input-group>.form-control+.custom-file,.input-group>.form-control+.custom-select,.input-group>.form-control+.form-control,.input-group>.form-control-plaintext+.custom-file,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.form-control{margin-left:-1px}.input-group>.custom-file .custom-file-input:focus~.custom-file-label,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.custom-select:not(:last-child),.input-group>.form-control:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-select:not(:first-child),.input-group>.form-control:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.custom-file{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label::after{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-append,.input-group-prepend{display:-ms-flexbox;display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn:focus,.input-group-prepend .btn:focus{z-index:3}.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.btn,.input-group-append .input-group-text+.input-group-text,.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.375rem .75rem;margin-bottom:0;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group-lg>.custom-select,.input-group-lg>.form-control:not(textarea){height:calc(1.5em + 1rem + 2px)}.input-group-lg>.custom-select,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.input-group-sm>.custom-select,.input-group-sm>.form-control:not(textarea){height:calc(1.5em + .5rem + 2px)}.input-group-sm>.custom-select,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child),.input-group>.input-group-append:not(:last-child)>.btn,.input-group>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-top-left-radius:0;border-bottom-left-radius:0}.custom-control{position:relative;display:block;min-height:1.5rem;padding-left:1.5rem}.custom-control-inline{display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;z-index:-1;opacity:0}.custom-control-input:checked~.custom-control-label::before{color:#fff;border-color:#007bff;background-color:#007bff}.custom-control-input:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-control-input:focus:not(:checked)~.custom-control-label::before{border-color:#80bdff}.custom-control-input:not(:disabled):active~.custom-control-label::before{color:#fff;background-color:#b3d7ff;border-color:#b3d7ff}.custom-control-input:disabled~.custom-control-label{color:#6c757d}.custom-control-input:disabled~.custom-control-label::before{background-color:#e9ecef}.custom-control-label{position:relative;margin-bottom:0;vertical-align:top}.custom-control-label::before{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";background-color:#fff;border:#adb5bd solid 1px}.custom-control-label::after{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;content:"";background:no-repeat 50%/50% 50%}.custom-checkbox .custom-control-label::before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::before{border-color:#007bff;background-color:#007bff}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label::before{left:-2.25rem;width:1.75rem;pointer-events:all;border-radius:.5rem}.custom-switch .custom-control-label::after{top:calc(.25rem + 2px);left:calc(-2.25rem + 2px);width:calc(1rem - 4px);height:calc(1rem - 4px);background-color:#adb5bd;border-radius:.5rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-switch .custom-control-label::after{transition:none}}.custom-switch .custom-control-input:checked~.custom-control-label::after{background-color:#fff;-webkit-transform:translateX(.75rem);transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-select{display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem 1.75rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;vertical-align:middle;background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-select:focus{border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-select:focus::-ms-value{color:#495057;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;padding-right:.75rem;background-image:none}.custom-select:disabled{color:#6c757d;background-color:#e9ecef}.custom-select::-ms-expand{display:none}.custom-select-sm{height:calc(1.5em + .5rem + 2px);padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.custom-select-lg{height:calc(1.5em + 1rem + 2px);padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.custom-file{position:relative;display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);margin-bottom:0}.custom-file-input{position:relative;z-index:2;width:100%;height:calc(1.5em + .75rem + 2px);margin:0;opacity:0}.custom-file-input:focus~.custom-file-label{border-color:#80bdff;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-file-input:disabled~.custom-file-label{background-color:#e9ecef}.custom-file-input:lang(en)~.custom-file-label::after{content:"Browse"}.custom-file-input~.custom-file-label[data-browse]::after{content:attr(data-browse)}.custom-file-label{position:absolute;top:0;right:0;left:0;z-index:1;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem}.custom-file-label::after{position:absolute;top:0;right:0;bottom:0;z-index:3;display:block;height:calc(1.5em + .75rem);padding:.375rem .75rem;line-height:1.5;color:#495057;content:"Browse";background-color:#e9ecef;border-left:inherit;border-radius:0 .25rem .25rem 0}.custom-range{width:100%;height:calc(1rem + .4rem);padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-range:focus{outline:0}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#007bff;border:0;border-radius:1rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-webkit-slider-thumb{transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#b3d7ff}.custom-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#007bff;border:0;border-radius:1rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-moz-range-thumb{transition:none}}.custom-range::-moz-range-thumb:active{background-color:#b3d7ff}.custom-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-ms-thumb{width:1rem;height:1rem;margin-top:0;margin-right:.2rem;margin-left:.2rem;background-color:#007bff;border:0;border-radius:1rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-ms-thumb{transition:none}}.custom-range::-ms-thumb:active{background-color:#b3d7ff}.custom-range::-ms-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:transparent;border-color:transparent;border-width:.5rem}.custom-range::-ms-fill-lower{background-color:#dee2e6;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px;background-color:#dee2e6;border-radius:1rem}.custom-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#adb5bd}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#adb5bd}.custom-control-label::before,.custom-file-label,.custom-select{transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-control-label::before,.custom-file-label,.custom-select{transition:none}}.nav{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-item{margin-bottom:-1px}.nav-tabs .nav-link{border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#007bff}.nav-fill .nav-item{-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between;padding:.5rem 1rem}.navbar>.container,.navbar>.container-fluid{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;line-height:inherit;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-positive:1;flex-grow:1;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:no-repeat center center;background-size:100% 100%}@media (max-width:575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:576px){.navbar-expand-sm{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width:767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:768px){.navbar-expand-md{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width:991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:992px){.navbar-expand-lg{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width:1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:1200px){.navbar-expand-xl{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid{padding-right:0;padding-left:0}.navbar-expand .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.5);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(0, 0, 0, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.5)}.navbar-light .navbar-text a{color:rgba(0,0,0,.9)}.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.5)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.5);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.5)}.navbar-dark .navbar-text a{color:#fff}.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group:first-child .list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.card>.list-group:last-child .list-group-item:last-child{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.card-body{-ms-flex:1 1 auto;flex:1 1 auto;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{padding:.75rem 1.25rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-header+.list-group .list-group-item:first-child{border-top:0}.card-footer{padding:.75rem 1.25rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.625rem;margin-bottom:-.75rem;margin-left:-.625rem;border-bottom:0}.card-header-pills{margin-right:-.625rem;margin-left:-.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem}.card-img{width:100%;border-radius:calc(.25rem - 1px)}.card-img-top{width:100%;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img-bottom{width:100%;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-deck{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.card-deck .card{margin-bottom:15px}@media (min-width:576px){.card-deck{-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{display:-ms-flexbox;display:flex;-ms-flex:1 0 0%;flex:1 0 0%;-ms-flex-direction:column;flex-direction:column;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.card-group>.card{margin-bottom:15px}@media (min-width:576px){.card-group{-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width:576px){.card-columns{-webkit-column-count:3;-moz-column-count:3;column-count:3;-webkit-column-gap:1.25rem;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion>.card{overflow:hidden}.accordion>.card:not(:first-of-type) .card-header:first-child{border-radius:0}.accordion>.card:not(:first-of-type):not(:last-of-type){border-bottom:0;border-radius:0}.accordion>.card:first-of-type{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.accordion>.card:last-of-type{border-top-left-radius:0;border-top-right-radius:0}.accordion>.card .card-header{margin-bottom:-1px}.breadcrumb{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:.75rem 1rem;margin-bottom:1rem;list-style:none;background-color:#e9ecef;border-radius:.25rem}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:.5rem;color:#6c757d;content:"/"}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:none}.breadcrumb-item.active{color:#6c757d}.pagination{display:-ms-flexbox;display:flex;padding-left:0;list-style:none;border-radius:.25rem}.page-link{position:relative;display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1.25;color:#007bff;background-color:#fff;border:1px solid #dee2e6}.page-link:hover{z-index:2;color:#0056b3;text-decoration:none;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:2;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.page-item:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.page-item.active .page-link{z-index:1;color:#fff;background-color:#007bff;border-color:#007bff}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;cursor:auto;background-color:#fff;border-color:#dee2e6}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem;line-height:1.5}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem;line-height:1.5}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.badge{transition:none}}a.badge:focus,a.badge:hover{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:.6em;padding-left:.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#007bff}a.badge-primary:focus,a.badge-primary:hover{color:#fff;background-color:#0062cc}a.badge-primary.focus,a.badge-primary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.badge-secondary{color:#fff;background-color:#6c757d}a.badge-secondary:focus,a.badge-secondary:hover{color:#fff;background-color:#545b62}a.badge-secondary.focus,a.badge-secondary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.badge-success{color:#fff;background-color:#28a745}a.badge-success:focus,a.badge-success:hover{color:#fff;background-color:#1e7e34}a.badge-success.focus,a.badge-success:focus{outline:0;box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.badge-info{color:#fff;background-color:#17a2b8}a.badge-info:focus,a.badge-info:hover{color:#fff;background-color:#117a8b}a.badge-info.focus,a.badge-info:focus{outline:0;box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.badge-warning{color:#212529;background-color:#ffc107}a.badge-warning:focus,a.badge-warning:hover{color:#212529;background-color:#d39e00}a.badge-warning.focus,a.badge-warning:focus{outline:0;box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.badge-danger{color:#fff;background-color:#dc3545}a.badge-danger:focus,a.badge-danger:hover{color:#fff;background-color:#bd2130}a.badge-danger.focus,a.badge-danger:focus{outline:0;box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.badge-light{color:#212529;background-color:#f8f9fa}a.badge-light:focus,a.badge-light:hover{color:#212529;background-color:#dae0e5}a.badge-light.focus,a.badge-light:focus{outline:0;box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.badge-dark{color:#fff;background-color:#343a40}a.badge-dark:focus,a.badge-dark:hover{color:#fff;background-color:#1d2124}a.badge-dark.focus,a.badge-dark:focus{outline:0;box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;background-color:#e9ecef;border-radius:.3rem}@media (min-width:576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.alert{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{position:absolute;top:0;right:0;padding:.75rem 1.25rem;color:inherit}.alert-primary{color:#004085;background-color:#cce5ff;border-color:#b8daff}.alert-primary hr{border-top-color:#9fcdff}.alert-primary .alert-link{color:#002752}.alert-secondary{color:#383d41;background-color:#e2e3e5;border-color:#d6d8db}.alert-secondary hr{border-top-color:#c8cbcf}.alert-secondary .alert-link{color:#202326}.alert-success{color:#155724;background-color:#d4edda;border-color:#c3e6cb}.alert-success hr{border-top-color:#b1dfbb}.alert-success .alert-link{color:#0b2e13}.alert-info{color:#0c5460;background-color:#d1ecf1;border-color:#bee5eb}.alert-info hr{border-top-color:#abdde5}.alert-info .alert-link{color:#062c33}.alert-warning{color:#856404;background-color:#fff3cd;border-color:#ffeeba}.alert-warning hr{border-top-color:#ffe8a1}.alert-warning .alert-link{color:#533f03}.alert-danger{color:#721c24;background-color:#f8d7da;border-color:#f5c6cb}.alert-danger hr{border-top-color:#f1b0b7}.alert-danger .alert-link{color:#491217}.alert-light{color:#818182;background-color:#fefefe;border-color:#fdfdfe}.alert-light hr{border-top-color:#ececf6}.alert-light .alert-link{color:#686868}.alert-dark{color:#1b1e21;background-color:#d6d8d9;border-color:#c6c8ca}.alert-dark hr{border-top-color:#b9bbbe}.alert-dark .alert-link{color:#040505}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;color:#fff;text-align:center;white-space:nowrap;background-color:#007bff;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:progress-bar-stripes 1s linear infinite;animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.media{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start}.media-body{-ms-flex:1;flex:1}.list-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;margin-bottom:-1px;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#007bff;border-color:#007bff}.list-group-horizontal{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}@media (min-width:576px){.list-group-horizontal-sm{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-sm .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-sm .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}@media (min-width:768px){.list-group-horizontal-md{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-md .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-md .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}@media (min-width:992px){.list-group-horizontal-lg{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-lg .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-lg .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}@media (min-width:1200px){.list-group-horizontal-xl{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-xl .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-xl .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}.list-group-flush .list-group-item{border-right:0;border-left:0;border-radius:0}.list-group-flush .list-group-item:last-child{margin-bottom:-1px}.list-group-flush:first-child .list-group-item:first-child{border-top:0}.list-group-flush:last-child .list-group-item:last-child{margin-bottom:0;border-bottom:0}.list-group-item-primary{color:#004085;background-color:#b8daff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#004085;background-color:#9fcdff}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#004085;border-color:#004085}.list-group-item-secondary{color:#383d41;background-color:#d6d8db}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#383d41;background-color:#c8cbcf}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#383d41;border-color:#383d41}.list-group-item-success{color:#155724;background-color:#c3e6cb}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#155724;background-color:#b1dfbb}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#155724;border-color:#155724}.list-group-item-info{color:#0c5460;background-color:#bee5eb}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#0c5460;background-color:#abdde5}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#0c5460;border-color:#0c5460}.list-group-item-warning{color:#856404;background-color:#ffeeba}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#856404;background-color:#ffe8a1}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#856404;border-color:#856404}.list-group-item-danger{color:#721c24;background-color:#f5c6cb}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#721c24;background-color:#f1b0b7}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#721c24;border-color:#721c24}.list-group-item-light{color:#818182;background-color:#fdfdfe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#818182;background-color:#ececf6}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#818182;border-color:#818182}.list-group-item-dark{color:#1b1e21;background-color:#c6c8ca}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#1b1e21;background-color:#b9bbbe}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#1b1e21;border-color:#1b1e21}.close{float:right;font-size:1.5rem;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.5}.close:hover{color:#000;text-decoration:none}.close:not(:disabled):not(.disabled):focus,.close:not(:disabled):not(.disabled):hover{opacity:.75}button.close{padding:0;background-color:transparent;border:0;-webkit-appearance:none;-moz-appearance:none;appearance:none}a.close.disabled{pointer-events:none}.toast{max-width:350px;overflow:hidden;font-size:.875rem;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .25rem .75rem rgba(0,0,0,.1);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);opacity:0;border-radius:.25rem}.toast:not(:last-child){margin-bottom:.75rem}.toast.showing{opacity:1}.toast.show{display:block;opacity:1}.toast.hide{display:none}.toast-header{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.25rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05)}.toast-body{padding:.75rem}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow:hidden;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:-webkit-transform .3s ease-out;transition:transform .3s ease-out;transition:transform .3s ease-out,-webkit-transform .3s ease-out;-webkit-transform:translate(0,-50px);transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{-webkit-transform:none;transform:none}.modal-dialog-scrollable{display:-ms-flexbox;display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-footer,.modal-dialog-scrollable .modal-header{-ms-flex-negative:0;flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;min-height:calc(100% - 1rem)}.modal-dialog-centered::before{display:block;height:calc(100vh - 1rem);content:""}.modal-dialog-centered.modal-dialog-scrollable{-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;height:100%}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable::before{content:none}.modal-content{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:justify;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:.3rem;border-top-right-radius:.3rem}.modal-header .close{padding:1rem 1rem;margin:-1rem -1rem -1rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:end;justify-content:flex-end;padding:1rem;border-top:1px solid #dee2e6;border-bottom-right-radius:.3rem;border-bottom-left-radius:.3rem}.modal-footer>:not(:first-child){margin-left:.25rem}.modal-footer>:not(:last-child){margin-right:.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered::before{height:calc(100vh - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow::before,.bs-tooltip-top .arrow::before{top:0;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=right] .arrow::before,.bs-tooltip-right .arrow::before{right:0;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow::before,.bs-tooltip-bottom .arrow::before{bottom:0;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=left] .arrow::before,.bs-tooltip-left .arrow::before{left:0;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1060;display:block;max-width:276px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .arrow{position:absolute;display:block;width:1rem;height:.5rem;margin:0 .3rem}.popover .arrow::after,.popover .arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top]>.arrow,.bs-popover-top>.arrow{bottom:calc((.5rem + 1px) * -1)}.bs-popover-auto[x-placement^=top]>.arrow::before,.bs-popover-top>.arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=top]>.arrow::after,.bs-popover-top>.arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right]>.arrow,.bs-popover-right>.arrow{left:calc((.5rem + 1px) * -1);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=right]>.arrow::before,.bs-popover-right>.arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=right]>.arrow::after,.bs-popover-right>.arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom]>.arrow,.bs-popover-bottom>.arrow{top:calc((.5rem + 1px) * -1)}.bs-popover-auto[x-placement^=bottom]>.arrow::before,.bs-popover-bottom>.arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=bottom]>.arrow::after,.bs-popover-bottom>.arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[x-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f7f7f7}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left]>.arrow,.bs-popover-left>.arrow{right:calc((.5rem + 1px) * -1);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=left]>.arrow::before,.bs-popover-left>.arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=left]>.arrow::after,.bs-popover-left>.arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem .75rem;margin-bottom:0;font-size:1rem;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:.5rem .75rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{-ms-touch-action:pan-y;touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:-webkit-transform .6s ease-in-out;transition:transform .6s ease-in-out;transition:transform .6s ease-in-out,-webkit-transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-right,.carousel-item-next:not(.carousel-item-left){-webkit-transform:translateX(100%);transform:translateX(100%)}.active.carousel-item-left,.carousel-item-prev:not(.carousel-item-right){-webkit-transform:translateX(-100%);transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;-webkit-transform:none;transform:none}.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{z-index:0;opacity:0;transition:0s .6s opacity}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:15%;color:#fff;text-align:center;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:20px;height:20px;background:no-repeat 50%/100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:15;display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;padding-left:0;margin-right:15%;margin-left:15%;list-style:none}.carousel-indicators li{box-sizing:content-box;-ms-flex:0 1 auto;flex:0 1 auto;width:30px;height:3px;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators li{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center}@-webkit-keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:spinner-border .75s linear infinite;animation:spinner-border .75s linear infinite}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1}}@keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:spinner-grow .75s linear infinite;animation:spinner-grow .75s linear infinite}.spinner-grow-sm{width:1rem;height:1rem}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-primary{background-color:#007bff!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#0062cc!important}.bg-secondary{background-color:#6c757d!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#545b62!important}.bg-success{background-color:#28a745!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#1e7e34!important}.bg-info{background-color:#17a2b8!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#117a8b!important}.bg-warning{background-color:#ffc107!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#d39e00!important}.bg-danger{background-color:#dc3545!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#bd2130!important}.bg-light{background-color:#f8f9fa!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#dae0e5!important}.bg-dark{background-color:#343a40!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#1d2124!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}.border{border:1px solid #dee2e6!important}.border-top{border-top:1px solid #dee2e6!important}.border-right{border-right:1px solid #dee2e6!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-left{border-left:1px solid #dee2e6!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border-color:#007bff!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#28a745!important}.border-info{border-color:#17a2b8!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#343a40!important}.border-white{border-color:#fff!important}.rounded-sm{border-radius:.2rem!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-right{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-lg{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-0{border-radius:0!important}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:-ms-flexbox!important;display:flex!important}.d-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}@media (min-width:576px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:-ms-flexbox!important;display:flex!important}.d-sm-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:768px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:-ms-flexbox!important;display:flex!important}.d-md-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:992px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:-ms-flexbox!important;display:flex!important}.d-lg-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:1200px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:-ms-flexbox!important;display:flex!important}.d-xl-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:-ms-flexbox!important;display:flex!important}.d-print-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.857143%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-center{-ms-flex-align:center!important;align-items:center!important}.align-items-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}@media (min-width:576px){.flex-sm-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-sm-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-sm-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-sm-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-sm-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-sm-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-sm-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-sm-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-sm-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-sm-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-sm-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-sm-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-sm-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-sm-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-sm-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-sm-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-sm-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-sm-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-sm-center{-ms-flex-align:center!important;align-items:center!important}.align-items-sm-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-sm-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-sm-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-sm-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-sm-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-sm-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-sm-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-sm-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-sm-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-sm-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-sm-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-sm-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-sm-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-sm-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:768px){.flex-md-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-md-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-md-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-md-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-md-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-md-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-md-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-md-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-md-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-md-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-md-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-md-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-md-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-md-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-md-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-md-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-md-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-md-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-md-center{-ms-flex-align:center!important;align-items:center!important}.align-items-md-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-md-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-md-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-md-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-md-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-md-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-md-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-md-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-md-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-md-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-md-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-md-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-md-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-md-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:992px){.flex-lg-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-lg-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-lg-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-lg-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-lg-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-lg-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-lg-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-lg-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-lg-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-lg-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-lg-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-lg-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-lg-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-lg-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-lg-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-lg-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-lg-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-lg-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-lg-center{-ms-flex-align:center!important;align-items:center!important}.align-items-lg-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-lg-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-lg-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-lg-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-lg-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-lg-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-lg-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-lg-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-lg-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-lg-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-lg-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-lg-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-lg-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-lg-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:1200px){.flex-xl-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-xl-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-xl-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-xl-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-xl-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-xl-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-xl-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-xl-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-xl-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-xl-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-xl-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-xl-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-xl-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-xl-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-xl-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-xl-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-xl-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-xl-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-xl-center{-ms-flex-align:center!important;align-items:center!important}.align-items-xl-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-xl-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-xl-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-xl-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-xl-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-xl-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-xl-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-xl-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-xl-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-xl-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-xl-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-xl-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-xl-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-xl-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:576px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:768px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:992px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:1200px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}@supports ((position:-webkit-sticky) or (position:sticky)){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.min-vw-100{min-width:100vw!important}.min-vh-100{min-height:100vh!important}.vw-100{width:100vw!important}.vh-100{height:100vh!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:auto;content:"";background-color:rgba(0,0,0,0)}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5{padding-left:3rem!important}.m-n1{margin:-.25rem!important}.mt-n1,.my-n1{margin-top:-.25rem!important}.mr-n1,.mx-n1{margin-right:-.25rem!important}.mb-n1,.my-n1{margin-bottom:-.25rem!important}.ml-n1,.mx-n1{margin-left:-.25rem!important}.m-n2{margin:-.5rem!important}.mt-n2,.my-n2{margin-top:-.5rem!important}.mr-n2,.mx-n2{margin-right:-.5rem!important}.mb-n2,.my-n2{margin-bottom:-.5rem!important}.ml-n2,.mx-n2{margin-left:-.5rem!important}.m-n3{margin:-1rem!important}.mt-n3,.my-n3{margin-top:-1rem!important}.mr-n3,.mx-n3{margin-right:-1rem!important}.mb-n3,.my-n3{margin-bottom:-1rem!important}.ml-n3,.mx-n3{margin-left:-1rem!important}.m-n4{margin:-1.5rem!important}.mt-n4,.my-n4{margin-top:-1.5rem!important}.mr-n4,.mx-n4{margin-right:-1.5rem!important}.mb-n4,.my-n4{margin-bottom:-1.5rem!important}.ml-n4,.mx-n4{margin-left:-1.5rem!important}.m-n5{margin:-3rem!important}.mt-n5,.my-n5{margin-top:-3rem!important}.mr-n5,.mx-n5{margin-right:-3rem!important}.mb-n5,.my-n5{margin-bottom:-3rem!important}.ml-n5,.mx-n5{margin-left:-3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto{margin-left:auto!important}@media (min-width:576px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5{padding-left:3rem!important}.m-sm-n1{margin:-.25rem!important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem!important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem!important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem!important}.ml-sm-n1,.mx-sm-n1{margin-left:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem!important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem!important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem!important}.ml-sm-n2,.mx-sm-n2{margin-left:-.5rem!important}.m-sm-n3{margin:-1rem!important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem!important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem!important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem!important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem!important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem!important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem!important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem!important}.m-sm-n5{margin:-3rem!important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem!important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem!important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem!important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto{margin-left:auto!important}}@media (min-width:768px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5{padding-left:3rem!important}.m-md-n1{margin:-.25rem!important}.mt-md-n1,.my-md-n1{margin-top:-.25rem!important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem!important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem!important}.ml-md-n1,.mx-md-n1{margin-left:-.25rem!important}.m-md-n2{margin:-.5rem!important}.mt-md-n2,.my-md-n2{margin-top:-.5rem!important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem!important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem!important}.ml-md-n2,.mx-md-n2{margin-left:-.5rem!important}.m-md-n3{margin:-1rem!important}.mt-md-n3,.my-md-n3{margin-top:-1rem!important}.mr-md-n3,.mx-md-n3{margin-right:-1rem!important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem!important}.ml-md-n3,.mx-md-n3{margin-left:-1rem!important}.m-md-n4{margin:-1.5rem!important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem!important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem!important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem!important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem!important}.m-md-n5{margin:-3rem!important}.mt-md-n5,.my-md-n5{margin-top:-3rem!important}.mr-md-n5,.mx-md-n5{margin-right:-3rem!important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem!important}.ml-md-n5,.mx-md-n5{margin-left:-3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto{margin-left:auto!important}}@media (min-width:992px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5{padding-left:3rem!important}.m-lg-n1{margin:-.25rem!important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem!important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem!important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem!important}.ml-lg-n1,.mx-lg-n1{margin-left:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem!important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem!important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem!important}.ml-lg-n2,.mx-lg-n2{margin-left:-.5rem!important}.m-lg-n3{margin:-1rem!important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem!important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem!important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem!important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem!important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem!important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem!important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem!important}.m-lg-n5{margin:-3rem!important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem!important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem!important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem!important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto{margin-left:auto!important}}@media (min-width:1200px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5{padding-left:3rem!important}.m-xl-n1{margin:-.25rem!important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem!important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem!important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem!important}.ml-xl-n1,.mx-xl-n1{margin-left:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem!important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem!important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem!important}.ml-xl-n2,.mx-xl-n2{margin-left:-.5rem!important}.m-xl-n3{margin:-1rem!important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem!important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem!important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem!important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem!important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem!important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem!important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem!important}.m-xl-n5{margin:-3rem!important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem!important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem!important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem!important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto{margin-left:auto!important}}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace!important}.text-justify{text-align:justify!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:576px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-lighter{font-weight:lighter!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:700!important}.font-weight-bolder{font-weight:bolder!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#007bff!important}a.text-primary:focus,a.text-primary:hover{color:#0056b3!important}.text-secondary{color:#6c757d!important}a.text-secondary:focus,a.text-secondary:hover{color:#494f54!important}.text-success{color:#28a745!important}a.text-success:focus,a.text-success:hover{color:#19692c!important}.text-info{color:#17a2b8!important}a.text-info:focus,a.text-info:hover{color:#0f6674!important}.text-warning{color:#ffc107!important}a.text-warning:focus,a.text-warning:hover{color:#ba8b00!important}.text-danger{color:#dc3545!important}a.text-danger:focus,a.text-danger:hover{color:#a71d2a!important}.text-light{color:#f8f9fa!important}a.text-light:focus,a.text-light:hover{color:#cbd3da!important}.text-dark{color:#343a40!important}a.text-dark:focus,a.text-dark:hover{color:#121416!important}.text-body{color:#212529!important}.text-muted{color:#6c757d!important}.text-black-50{color:rgba(0,0,0,.5)!important}.text-white-50{color:rgba(255,255,255,.5)!important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.text-decoration-none{text-decoration:none!important}.text-break{word-break:break-word!important;overflow-wrap:break-word!important}.text-reset{color:inherit!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media print{*,::after,::before{text-shadow:none!important;box-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #adb5bd;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}body{min-width:992px!important}.container{min-width:992px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #dee2e6!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#dee2e6}.table .thead-dark th{color:inherit;border-color:#dee2e6}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/ErsatzTV/wwwroot/css/bootstrap/bootstrap.min.css.map b/ErsatzTV/wwwroot/css/bootstrap/bootstrap.min.css.map new file mode 100644 index 000000000..1e9cb78a5 --- /dev/null +++ b/ErsatzTV/wwwroot/css/bootstrap/bootstrap.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/bootstrap.scss","../../scss/_root.scss","../../scss/_reboot.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","bootstrap.css","../../scss/mixins/_hover.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/mixins/_border-radius.scss","../../scss/_code.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/mixins/_breakpoints.scss","../../scss/mixins/_grid-framework.scss","../../scss/_tables.scss","../../scss/mixins/_table-row.scss","../../scss/_forms.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_forms.scss","../../scss/mixins/_gradients.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/mixins/_nav-divider.scss","../../scss/_button-group.scss","../../scss/_input-group.scss","../../scss/_custom-forms.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/mixins/_badge.scss","../../scss/_jumbotron.scss","../../scss/_alert.scss","../../scss/mixins/_alert.scss","../../scss/_progress.scss","../../scss/_media.scss","../../scss/_list-group.scss","../../scss/mixins/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/utilities/_align.scss","../../scss/mixins/_background-variant.scss","../../scss/utilities/_background.scss","../../scss/utilities/_borders.scss","../../scss/utilities/_display.scss","../../scss/utilities/_embed.scss","../../scss/utilities/_flex.scss","../../scss/utilities/_float.scss","../../scss/utilities/_overflow.scss","../../scss/utilities/_position.scss","../../scss/utilities/_screenreaders.scss","../../scss/mixins/_screen-reader.scss","../../scss/utilities/_shadows.scss","../../scss/utilities/_sizing.scss","../../scss/utilities/_stretched-link.scss","../../scss/utilities/_spacing.scss","../../scss/utilities/_text.scss","../../scss/mixins/_text-truncate.scss","../../scss/mixins/_text-emphasis.scss","../../scss/mixins/_text-hide.scss","../../scss/utilities/_visibility.scss","../../scss/_print.scss"],"names":[],"mappings":"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","sourcesContent":["/*!\n * Bootstrap v4.3.1 (https://getbootstrap.com/)\n * Copyright 2011-2019 The Bootstrap Authors\n * Copyright 2011-2019 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n\n@import \"functions\";\n@import \"variables\";\n@import \"mixins\";\n@import \"root\";\n@import \"reboot\";\n@import \"type\";\n@import \"images\";\n@import \"code\";\n@import \"grid\";\n@import \"tables\";\n@import \"forms\";\n@import \"buttons\";\n@import \"transitions\";\n@import \"dropdown\";\n@import \"button-group\";\n@import \"input-group\";\n@import \"custom-forms\";\n@import \"nav\";\n@import \"navbar\";\n@import \"card\";\n@import \"breadcrumb\";\n@import \"pagination\";\n@import \"badge\";\n@import \"jumbotron\";\n@import \"alert\";\n@import \"progress\";\n@import \"media\";\n@import \"list-group\";\n@import \"close\";\n@import \"toasts\";\n@import \"modal\";\n@import \"tooltip\";\n@import \"popover\";\n@import \"carousel\";\n@import \"spinners\";\n@import \"utilities\";\n@import \"print\";\n",":root {\n // Custom variable values only support SassScript inside `#{}`.\n @each $color, $value in $colors {\n --#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$color}: #{$value};\n }\n\n @each $bp, $value in $grid-breakpoints {\n --breakpoint-#{$bp}: #{$value};\n }\n\n // Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --font-family-sans-serif: #{inspect($font-family-sans-serif)};\n --font-family-monospace: #{inspect($font-family-monospace)};\n}\n","// stylelint-disable at-rule-no-vendor-prefix, declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// 1. Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n// 2. Change the default font family in all browsers.\n// 3. Correct the line height in all browsers.\n// 4. Prevent adjustments of font size after orientation changes in IE on Windows Phone and in iOS.\n// 5. Change the default tap highlight to be completely transparent in iOS.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box; // 1\n}\n\nhtml {\n font-family: sans-serif; // 2\n line-height: 1.15; // 3\n -webkit-text-size-adjust: 100%; // 4\n -webkit-tap-highlight-color: rgba($black, 0); // 5\n}\n\n// Shim for \"new\" HTML5 structural elements to display correctly (IE10, older browsers)\n// TODO: remove in v5\n// stylelint-disable-next-line selector-list-comma-newline-after\narticle, aside, figcaption, figure, footer, header, hgroup, main, nav, section {\n display: block;\n}\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Set an explicit initial text-align value so that we can later use\n// the `inherit` value on things like `` elements.\n\nbody {\n margin: 0; // 1\n font-family: $font-family-base;\n @include font-size($font-size-base);\n font-weight: $font-weight-base;\n line-height: $line-height-base;\n color: $body-color;\n text-align: left; // 3\n background-color: $body-bg; // 2\n}\n\n// Suppress the focus outline on elements that cannot be accessed via keyboard.\n// This prevents an unwanted focus outline from appearing around elements that\n// might still respond to pointer events.\n//\n// Credit: https://github.com/suitcss/base\n[tabindex=\"-1\"]:focus {\n outline: 0 !important;\n}\n\n\n// Content grouping\n//\n// 1. Add the correct box sizing in Firefox.\n// 2. Show the overflow in Edge and IE.\n\nhr {\n box-sizing: content-box; // 1\n height: 0; // 1\n overflow: visible; // 2\n}\n\n\n//\n// Typography\n//\n\n// Remove top margins from headings\n//\n// By default, `

`-`

` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n// stylelint-disable-next-line selector-list-comma-newline-after\nh1, h2, h3, h4, h5, h6 {\n margin-top: 0;\n margin-bottom: $headings-margin-bottom;\n}\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `

`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n// Abbreviations\n//\n// 1. Duplicate behavior to the data-* attribute for our tooltip plugin\n// 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Remove the bottom border in Firefox 39-.\n// 5. Prevent the text-decoration to be skipped.\n\nabbr[title],\nabbr[data-original-title] { // 1\n text-decoration: underline; // 2\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n border-bottom: 0; // 4\n text-decoration-skip-ink: none; // 5\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // Undo browser default\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: $font-weight-bolder; // Add the correct font weight in Chrome, Edge, and Safari\n}\n\nsmall {\n @include font-size(80%); // Add the correct font size in all browsers\n}\n\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n//\n\nsub,\nsup {\n position: relative;\n @include font-size(75%);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n//\n// Links\n//\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n background-color: transparent; // Remove the gray background on active links in IE 10.\n\n @include hover {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href)\n// which have not been made explicitly keyboard-focusable (without tabindex).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([tabindex]) {\n color: inherit;\n text-decoration: none;\n\n @include hover-focus {\n color: inherit;\n text-decoration: none;\n }\n\n &:focus {\n outline: 0;\n }\n}\n\n\n//\n// Code\n//\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-monospace;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n}\n\npre {\n // Remove browser default top margin\n margin-top: 0;\n // Reset browser default of `1em` to use `rem`s\n margin-bottom: 1rem;\n // Don't allow content to break outside\n overflow: auto;\n}\n\n\n//\n// Figures\n//\n\nfigure {\n // Apply a consistent margin strategy (matches our type styles).\n margin: 0 0 1rem;\n}\n\n\n//\n// Images and content\n//\n\nimg {\n vertical-align: middle;\n border-style: none; // Remove the border on images inside links in IE 10-.\n}\n\nsvg {\n // Workaround for the SVG overflow bug in IE10/11 is still required.\n // See https://github.com/twbs/bootstrap/issues/26878\n overflow: hidden;\n vertical-align: middle;\n}\n\n\n//\n// Tables\n//\n\ntable {\n border-collapse: collapse; // Prevent double borders\n}\n\ncaption {\n padding-top: $table-cell-padding;\n padding-bottom: $table-cell-padding;\n color: $table-caption-color;\n text-align: left;\n caption-side: bottom;\n}\n\nth {\n // Matches default `` alignment by inheriting from the ``, or the\n // closest parent with a set `text-align`.\n text-align: inherit;\n}\n\n\n//\n// Forms\n//\n\nlabel {\n // Allow labels to use `margin` for spacing.\n display: inline-block;\n margin-bottom: $label-margin-bottom;\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n//\n// Details at https://github.com/twbs/bootstrap/issues/24093\nbutton {\n // stylelint-disable-next-line property-blacklist\n border-radius: 0;\n}\n\n// Work around a Firefox/IE bug where the transparent `button` background\n// results in a loss of the default `button` focus styles.\n//\n// Credit: https://github.com/suitcss/base/\nbutton:focus {\n outline: 1px dotted;\n outline: 5px auto -webkit-focus-ring-color;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // Remove the margin in Firefox and Safari\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\nbutton,\ninput {\n overflow: visible; // Show the overflow in Edge\n}\n\nbutton,\nselect {\n text-transform: none; // Remove the inheritance of text transform in Firefox\n}\n\n// Remove the inheritance of word-wrap in Safari.\n//\n// Details at https://github.com/twbs/bootstrap/issues/24990\nselect {\n word-wrap: normal;\n}\n\n\n// 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`\n// controls in Android 4.\n// 2. Correct the inability to style clickable types in iOS and Safari.\nbutton,\n[type=\"button\"], // 1\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button; // 2\n}\n\n// Opinionated: add \"hand\" cursor to non-disabled button elements.\n@if $enable-pointer-cursor-for-buttons {\n button,\n [type=\"button\"],\n [type=\"reset\"],\n [type=\"submit\"] {\n &:not(:disabled) {\n cursor: pointer;\n }\n }\n}\n\n// Remove inner border and padding from Firefox, but don't restore the outline like Normalize.\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n box-sizing: border-box; // 1. Add the correct box sizing in IE 10-\n padding: 0; // 2. Remove the padding in IE 10-\n}\n\n\ninput[type=\"date\"],\ninput[type=\"time\"],\ninput[type=\"datetime-local\"],\ninput[type=\"month\"] {\n // Remove the default appearance of temporal inputs to avoid a Mobile Safari\n // bug where setting a custom line-height prevents text from being vertically\n // centered within the input.\n // See https://bugs.webkit.org/show_bug.cgi?id=139848\n // and https://github.com/twbs/bootstrap/issues/11266\n -webkit-appearance: listbox;\n}\n\ntextarea {\n overflow: auto; // Remove the default vertical scrollbar in IE.\n // Textareas should really only resize vertically so they don't break their (horizontal) containers.\n resize: vertical;\n}\n\nfieldset {\n // Browsers set a default `min-width: min-content;` on fieldsets,\n // unlike e.g. `

`s, which have `min-width: 0;` by default.\n // So we reset that to ensure fieldsets behave more like a standard block element.\n // See https://github.com/twbs/bootstrap/issues/12359\n // and https://html.spec.whatwg.org/multipage/#the-fieldset-and-legend-elements\n min-width: 0;\n // Reset the default outline behavior of fieldsets so they don't affect page layout.\n padding: 0;\n margin: 0;\n border: 0;\n}\n\n// 1. Correct the text wrapping in Edge and IE.\n// 2. Correct the color inheritance from `fieldset` elements in IE.\nlegend {\n display: block;\n width: 100%;\n max-width: 100%; // 1\n padding: 0;\n margin-bottom: .5rem;\n @include font-size(1.5rem);\n line-height: inherit;\n color: inherit; // 2\n white-space: normal; // 1\n}\n\nprogress {\n vertical-align: baseline; // Add the correct vertical alignment in Chrome, Firefox, and Opera.\n}\n\n// Correct the cursor style of increment and decrement buttons in Chrome.\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n[type=\"search\"] {\n // This overrides the extra rounded corners on search inputs in iOS so that our\n // `.form-control` class can properly style them. Note that this cannot simply\n // be added to `.form-control` as it's not specific enough. For details, see\n // https://github.com/twbs/bootstrap/issues/11586.\n outline-offset: -2px; // 2. Correct the outline style in Safari.\n -webkit-appearance: none;\n}\n\n//\n// Remove the inner padding in Chrome and Safari on macOS.\n//\n\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n//\n// 1. Correct the inability to style clickable types in iOS and Safari.\n// 2. Change font properties to `inherit` in Safari.\n//\n\n::-webkit-file-upload-button {\n font: inherit; // 2\n -webkit-appearance: button; // 1\n}\n\n//\n// Correct element displays\n//\n\noutput {\n display: inline-block;\n}\n\nsummary {\n display: list-item; // Add the correct display in all browsers\n cursor: pointer;\n}\n\ntemplate {\n display: none; // Add the correct display in IE\n}\n\n// Always hide an element with the `hidden` HTML attribute (from PureCSS).\n// Needed for proper display in IE 10-.\n[hidden] {\n display: none !important;\n}\n","/*!\n * Bootstrap v4.3.1 (https://getbootstrap.com/)\n * Copyright 2011-2019 The Bootstrap Authors\n * Copyright 2011-2019 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n:root {\n --blue: #007bff;\n --indigo: #6610f2;\n --purple: #6f42c1;\n --pink: #e83e8c;\n --red: #dc3545;\n --orange: #fd7e14;\n --yellow: #ffc107;\n --green: #28a745;\n --teal: #20c997;\n --cyan: #17a2b8;\n --white: #fff;\n --gray: #6c757d;\n --gray-dark: #343a40;\n --primary: #007bff;\n --secondary: #6c757d;\n --success: #28a745;\n --info: #17a2b8;\n --warning: #ffc107;\n --danger: #dc3545;\n --light: #f8f9fa;\n --dark: #343a40;\n --breakpoint-xs: 0;\n --breakpoint-sm: 576px;\n --breakpoint-md: 768px;\n --breakpoint-lg: 992px;\n --breakpoint-xl: 1200px;\n --font-family-sans-serif: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n --font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n}\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-family: sans-serif;\n line-height: 1.15;\n -webkit-text-size-adjust: 100%;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\narticle, aside, figcaption, figure, footer, header, hgroup, main, nav, section {\n display: block;\n}\n\nbody {\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #212529;\n text-align: left;\n background-color: #fff;\n}\n\n[tabindex=\"-1\"]:focus {\n outline: 0 !important;\n}\n\nhr {\n box-sizing: content-box;\n height: 0;\n overflow: visible;\n}\n\nh1, h2, h3, h4, h5, h6 {\n margin-top: 0;\n margin-bottom: 0.5rem;\n}\n\np {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nabbr[title],\nabbr[data-original-title] {\n text-decoration: underline;\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n cursor: help;\n border-bottom: 0;\n -webkit-text-decoration-skip-ink: none;\n text-decoration-skip-ink: none;\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: 700;\n}\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0;\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: bolder;\n}\n\nsmall {\n font-size: 80%;\n}\n\nsub,\nsup {\n position: relative;\n font-size: 75%;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -.25em;\n}\n\nsup {\n top: -.5em;\n}\n\na {\n color: #007bff;\n text-decoration: none;\n background-color: transparent;\n}\n\na:hover {\n color: #0056b3;\n text-decoration: underline;\n}\n\na:not([href]):not([tabindex]) {\n color: inherit;\n text-decoration: none;\n}\n\na:not([href]):not([tabindex]):hover, a:not([href]):not([tabindex]):focus {\n color: inherit;\n text-decoration: none;\n}\n\na:not([href]):not([tabindex]):focus {\n outline: 0;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n font-size: 1em;\n}\n\npre {\n margin-top: 0;\n margin-bottom: 1rem;\n overflow: auto;\n}\n\nfigure {\n margin: 0 0 1rem;\n}\n\nimg {\n vertical-align: middle;\n border-style: none;\n}\n\nsvg {\n overflow: hidden;\n vertical-align: middle;\n}\n\ntable {\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: 0.75rem;\n padding-bottom: 0.75rem;\n color: #6c757d;\n text-align: left;\n caption-side: bottom;\n}\n\nth {\n text-align: inherit;\n}\n\nlabel {\n display: inline-block;\n margin-bottom: 0.5rem;\n}\n\nbutton {\n border-radius: 0;\n}\n\nbutton:focus {\n outline: 1px dotted;\n outline: 5px auto -webkit-focus-ring-color;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\nbutton,\ninput {\n overflow: visible;\n}\n\nbutton,\nselect {\n text-transform: none;\n}\n\nselect {\n word-wrap: normal;\n}\n\nbutton,\n[type=\"button\"],\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button;\n}\n\nbutton:not(:disabled),\n[type=\"button\"]:not(:disabled),\n[type=\"reset\"]:not(:disabled),\n[type=\"submit\"]:not(:disabled) {\n cursor: pointer;\n}\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n box-sizing: border-box;\n padding: 0;\n}\n\ninput[type=\"date\"],\ninput[type=\"time\"],\ninput[type=\"datetime-local\"],\ninput[type=\"month\"] {\n -webkit-appearance: listbox;\n}\n\ntextarea {\n overflow: auto;\n resize: vertical;\n}\n\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\n\nlegend {\n display: block;\n width: 100%;\n max-width: 100%;\n padding: 0;\n margin-bottom: .5rem;\n font-size: 1.5rem;\n line-height: inherit;\n color: inherit;\n white-space: normal;\n}\n\nprogress {\n vertical-align: baseline;\n}\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n[type=\"search\"] {\n outline-offset: -2px;\n -webkit-appearance: none;\n}\n\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n::-webkit-file-upload-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\noutput {\n display: inline-block;\n}\n\nsummary {\n display: list-item;\n cursor: pointer;\n}\n\ntemplate {\n display: none;\n}\n\n[hidden] {\n display: none !important;\n}\n\nh1, h2, h3, h4, h5, h6,\n.h1, .h2, .h3, .h4, .h5, .h6 {\n margin-bottom: 0.5rem;\n font-weight: 500;\n line-height: 1.2;\n}\n\nh1, .h1 {\n font-size: 2.5rem;\n}\n\nh2, .h2 {\n font-size: 2rem;\n}\n\nh3, .h3 {\n font-size: 1.75rem;\n}\n\nh4, .h4 {\n font-size: 1.5rem;\n}\n\nh5, .h5 {\n font-size: 1.25rem;\n}\n\nh6, .h6 {\n font-size: 1rem;\n}\n\n.lead {\n font-size: 1.25rem;\n font-weight: 300;\n}\n\n.display-1 {\n font-size: 6rem;\n font-weight: 300;\n line-height: 1.2;\n}\n\n.display-2 {\n font-size: 5.5rem;\n font-weight: 300;\n line-height: 1.2;\n}\n\n.display-3 {\n font-size: 4.5rem;\n font-weight: 300;\n line-height: 1.2;\n}\n\n.display-4 {\n font-size: 3.5rem;\n font-weight: 300;\n line-height: 1.2;\n}\n\nhr {\n margin-top: 1rem;\n margin-bottom: 1rem;\n border: 0;\n border-top: 1px solid rgba(0, 0, 0, 0.1);\n}\n\nsmall,\n.small {\n font-size: 80%;\n font-weight: 400;\n}\n\nmark,\n.mark {\n padding: 0.2em;\n background-color: #fcf8e3;\n}\n\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n\n.list-inline {\n padding-left: 0;\n list-style: none;\n}\n\n.list-inline-item {\n display: inline-block;\n}\n\n.list-inline-item:not(:last-child) {\n margin-right: 0.5rem;\n}\n\n.initialism {\n font-size: 90%;\n text-transform: uppercase;\n}\n\n.blockquote {\n margin-bottom: 1rem;\n font-size: 1.25rem;\n}\n\n.blockquote-footer {\n display: block;\n font-size: 80%;\n color: #6c757d;\n}\n\n.blockquote-footer::before {\n content: \"\\2014\\00A0\";\n}\n\n.img-fluid {\n max-width: 100%;\n height: auto;\n}\n\n.img-thumbnail {\n padding: 0.25rem;\n background-color: #fff;\n border: 1px solid #dee2e6;\n border-radius: 0.25rem;\n max-width: 100%;\n height: auto;\n}\n\n.figure {\n display: inline-block;\n}\n\n.figure-img {\n margin-bottom: 0.5rem;\n line-height: 1;\n}\n\n.figure-caption {\n font-size: 90%;\n color: #6c757d;\n}\n\ncode {\n font-size: 87.5%;\n color: #e83e8c;\n word-break: break-word;\n}\n\na > code {\n color: inherit;\n}\n\nkbd {\n padding: 0.2rem 0.4rem;\n font-size: 87.5%;\n color: #fff;\n background-color: #212529;\n border-radius: 0.2rem;\n}\n\nkbd kbd {\n padding: 0;\n font-size: 100%;\n font-weight: 700;\n}\n\npre {\n display: block;\n font-size: 87.5%;\n color: #212529;\n}\n\npre code {\n font-size: inherit;\n color: inherit;\n word-break: normal;\n}\n\n.pre-scrollable {\n max-height: 340px;\n overflow-y: scroll;\n}\n\n.container {\n width: 100%;\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n\n@media (min-width: 576px) {\n .container {\n max-width: 540px;\n }\n}\n\n@media (min-width: 768px) {\n .container {\n max-width: 720px;\n }\n}\n\n@media (min-width: 992px) {\n .container {\n max-width: 960px;\n }\n}\n\n@media (min-width: 1200px) {\n .container {\n max-width: 1140px;\n }\n}\n\n.container-fluid {\n width: 100%;\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n\n.row {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-wrap: wrap;\n flex-wrap: wrap;\n margin-right: -15px;\n margin-left: -15px;\n}\n\n.no-gutters {\n margin-right: 0;\n margin-left: 0;\n}\n\n.no-gutters > .col,\n.no-gutters > [class*=\"col-\"] {\n padding-right: 0;\n padding-left: 0;\n}\n\n.col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12, .col,\n.col-auto, .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm,\n.col-sm-auto, .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12, .col-md,\n.col-md-auto, .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg,\n.col-lg-auto, .col-xl-1, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl,\n.col-xl-auto {\n position: relative;\n width: 100%;\n padding-right: 15px;\n padding-left: 15px;\n}\n\n.col {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n -ms-flex-positive: 1;\n flex-grow: 1;\n max-width: 100%;\n}\n\n.col-auto {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n width: auto;\n max-width: 100%;\n}\n\n.col-1 {\n -ms-flex: 0 0 8.333333%;\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n}\n\n.col-2 {\n -ms-flex: 0 0 16.666667%;\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n}\n\n.col-3 {\n -ms-flex: 0 0 25%;\n flex: 0 0 25%;\n max-width: 25%;\n}\n\n.col-4 {\n -ms-flex: 0 0 33.333333%;\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n}\n\n.col-5 {\n -ms-flex: 0 0 41.666667%;\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n}\n\n.col-6 {\n -ms-flex: 0 0 50%;\n flex: 0 0 50%;\n max-width: 50%;\n}\n\n.col-7 {\n -ms-flex: 0 0 58.333333%;\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n}\n\n.col-8 {\n -ms-flex: 0 0 66.666667%;\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n}\n\n.col-9 {\n -ms-flex: 0 0 75%;\n flex: 0 0 75%;\n max-width: 75%;\n}\n\n.col-10 {\n -ms-flex: 0 0 83.333333%;\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n}\n\n.col-11 {\n -ms-flex: 0 0 91.666667%;\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n}\n\n.col-12 {\n -ms-flex: 0 0 100%;\n flex: 0 0 100%;\n max-width: 100%;\n}\n\n.order-first {\n -ms-flex-order: -1;\n order: -1;\n}\n\n.order-last {\n -ms-flex-order: 13;\n order: 13;\n}\n\n.order-0 {\n -ms-flex-order: 0;\n order: 0;\n}\n\n.order-1 {\n -ms-flex-order: 1;\n order: 1;\n}\n\n.order-2 {\n -ms-flex-order: 2;\n order: 2;\n}\n\n.order-3 {\n -ms-flex-order: 3;\n order: 3;\n}\n\n.order-4 {\n -ms-flex-order: 4;\n order: 4;\n}\n\n.order-5 {\n -ms-flex-order: 5;\n order: 5;\n}\n\n.order-6 {\n -ms-flex-order: 6;\n order: 6;\n}\n\n.order-7 {\n -ms-flex-order: 7;\n order: 7;\n}\n\n.order-8 {\n -ms-flex-order: 8;\n order: 8;\n}\n\n.order-9 {\n -ms-flex-order: 9;\n order: 9;\n}\n\n.order-10 {\n -ms-flex-order: 10;\n order: 10;\n}\n\n.order-11 {\n -ms-flex-order: 11;\n order: 11;\n}\n\n.order-12 {\n -ms-flex-order: 12;\n order: 12;\n}\n\n.offset-1 {\n margin-left: 8.333333%;\n}\n\n.offset-2 {\n margin-left: 16.666667%;\n}\n\n.offset-3 {\n margin-left: 25%;\n}\n\n.offset-4 {\n margin-left: 33.333333%;\n}\n\n.offset-5 {\n margin-left: 41.666667%;\n}\n\n.offset-6 {\n margin-left: 50%;\n}\n\n.offset-7 {\n margin-left: 58.333333%;\n}\n\n.offset-8 {\n margin-left: 66.666667%;\n}\n\n.offset-9 {\n margin-left: 75%;\n}\n\n.offset-10 {\n margin-left: 83.333333%;\n}\n\n.offset-11 {\n margin-left: 91.666667%;\n}\n\n@media (min-width: 576px) {\n .col-sm {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n -ms-flex-positive: 1;\n flex-grow: 1;\n max-width: 100%;\n }\n .col-sm-auto {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n width: auto;\n max-width: 100%;\n }\n .col-sm-1 {\n -ms-flex: 0 0 8.333333%;\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n }\n .col-sm-2 {\n -ms-flex: 0 0 16.666667%;\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n }\n .col-sm-3 {\n -ms-flex: 0 0 25%;\n flex: 0 0 25%;\n max-width: 25%;\n }\n .col-sm-4 {\n -ms-flex: 0 0 33.333333%;\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n }\n .col-sm-5 {\n -ms-flex: 0 0 41.666667%;\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n }\n .col-sm-6 {\n -ms-flex: 0 0 50%;\n flex: 0 0 50%;\n max-width: 50%;\n }\n .col-sm-7 {\n -ms-flex: 0 0 58.333333%;\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n }\n .col-sm-8 {\n -ms-flex: 0 0 66.666667%;\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n }\n .col-sm-9 {\n -ms-flex: 0 0 75%;\n flex: 0 0 75%;\n max-width: 75%;\n }\n .col-sm-10 {\n -ms-flex: 0 0 83.333333%;\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n }\n .col-sm-11 {\n -ms-flex: 0 0 91.666667%;\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n }\n .col-sm-12 {\n -ms-flex: 0 0 100%;\n flex: 0 0 100%;\n max-width: 100%;\n }\n .order-sm-first {\n -ms-flex-order: -1;\n order: -1;\n }\n .order-sm-last {\n -ms-flex-order: 13;\n order: 13;\n }\n .order-sm-0 {\n -ms-flex-order: 0;\n order: 0;\n }\n .order-sm-1 {\n -ms-flex-order: 1;\n order: 1;\n }\n .order-sm-2 {\n -ms-flex-order: 2;\n order: 2;\n }\n .order-sm-3 {\n -ms-flex-order: 3;\n order: 3;\n }\n .order-sm-4 {\n -ms-flex-order: 4;\n order: 4;\n }\n .order-sm-5 {\n -ms-flex-order: 5;\n order: 5;\n }\n .order-sm-6 {\n -ms-flex-order: 6;\n order: 6;\n }\n .order-sm-7 {\n -ms-flex-order: 7;\n order: 7;\n }\n .order-sm-8 {\n -ms-flex-order: 8;\n order: 8;\n }\n .order-sm-9 {\n -ms-flex-order: 9;\n order: 9;\n }\n .order-sm-10 {\n -ms-flex-order: 10;\n order: 10;\n }\n .order-sm-11 {\n -ms-flex-order: 11;\n order: 11;\n }\n .order-sm-12 {\n -ms-flex-order: 12;\n order: 12;\n }\n .offset-sm-0 {\n margin-left: 0;\n }\n .offset-sm-1 {\n margin-left: 8.333333%;\n }\n .offset-sm-2 {\n margin-left: 16.666667%;\n }\n .offset-sm-3 {\n margin-left: 25%;\n }\n .offset-sm-4 {\n margin-left: 33.333333%;\n }\n .offset-sm-5 {\n margin-left: 41.666667%;\n }\n .offset-sm-6 {\n margin-left: 50%;\n }\n .offset-sm-7 {\n margin-left: 58.333333%;\n }\n .offset-sm-8 {\n margin-left: 66.666667%;\n }\n .offset-sm-9 {\n margin-left: 75%;\n }\n .offset-sm-10 {\n margin-left: 83.333333%;\n }\n .offset-sm-11 {\n margin-left: 91.666667%;\n }\n}\n\n@media (min-width: 768px) {\n .col-md {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n -ms-flex-positive: 1;\n flex-grow: 1;\n max-width: 100%;\n }\n .col-md-auto {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n width: auto;\n max-width: 100%;\n }\n .col-md-1 {\n -ms-flex: 0 0 8.333333%;\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n }\n .col-md-2 {\n -ms-flex: 0 0 16.666667%;\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n }\n .col-md-3 {\n -ms-flex: 0 0 25%;\n flex: 0 0 25%;\n max-width: 25%;\n }\n .col-md-4 {\n -ms-flex: 0 0 33.333333%;\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n }\n .col-md-5 {\n -ms-flex: 0 0 41.666667%;\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n }\n .col-md-6 {\n -ms-flex: 0 0 50%;\n flex: 0 0 50%;\n max-width: 50%;\n }\n .col-md-7 {\n -ms-flex: 0 0 58.333333%;\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n }\n .col-md-8 {\n -ms-flex: 0 0 66.666667%;\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n }\n .col-md-9 {\n -ms-flex: 0 0 75%;\n flex: 0 0 75%;\n max-width: 75%;\n }\n .col-md-10 {\n -ms-flex: 0 0 83.333333%;\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n }\n .col-md-11 {\n -ms-flex: 0 0 91.666667%;\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n }\n .col-md-12 {\n -ms-flex: 0 0 100%;\n flex: 0 0 100%;\n max-width: 100%;\n }\n .order-md-first {\n -ms-flex-order: -1;\n order: -1;\n }\n .order-md-last {\n -ms-flex-order: 13;\n order: 13;\n }\n .order-md-0 {\n -ms-flex-order: 0;\n order: 0;\n }\n .order-md-1 {\n -ms-flex-order: 1;\n order: 1;\n }\n .order-md-2 {\n -ms-flex-order: 2;\n order: 2;\n }\n .order-md-3 {\n -ms-flex-order: 3;\n order: 3;\n }\n .order-md-4 {\n -ms-flex-order: 4;\n order: 4;\n }\n .order-md-5 {\n -ms-flex-order: 5;\n order: 5;\n }\n .order-md-6 {\n -ms-flex-order: 6;\n order: 6;\n }\n .order-md-7 {\n -ms-flex-order: 7;\n order: 7;\n }\n .order-md-8 {\n -ms-flex-order: 8;\n order: 8;\n }\n .order-md-9 {\n -ms-flex-order: 9;\n order: 9;\n }\n .order-md-10 {\n -ms-flex-order: 10;\n order: 10;\n }\n .order-md-11 {\n -ms-flex-order: 11;\n order: 11;\n }\n .order-md-12 {\n -ms-flex-order: 12;\n order: 12;\n }\n .offset-md-0 {\n margin-left: 0;\n }\n .offset-md-1 {\n margin-left: 8.333333%;\n }\n .offset-md-2 {\n margin-left: 16.666667%;\n }\n .offset-md-3 {\n margin-left: 25%;\n }\n .offset-md-4 {\n margin-left: 33.333333%;\n }\n .offset-md-5 {\n margin-left: 41.666667%;\n }\n .offset-md-6 {\n margin-left: 50%;\n }\n .offset-md-7 {\n margin-left: 58.333333%;\n }\n .offset-md-8 {\n margin-left: 66.666667%;\n }\n .offset-md-9 {\n margin-left: 75%;\n }\n .offset-md-10 {\n margin-left: 83.333333%;\n }\n .offset-md-11 {\n margin-left: 91.666667%;\n }\n}\n\n@media (min-width: 992px) {\n .col-lg {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n -ms-flex-positive: 1;\n flex-grow: 1;\n max-width: 100%;\n }\n .col-lg-auto {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n width: auto;\n max-width: 100%;\n }\n .col-lg-1 {\n -ms-flex: 0 0 8.333333%;\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n }\n .col-lg-2 {\n -ms-flex: 0 0 16.666667%;\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n }\n .col-lg-3 {\n -ms-flex: 0 0 25%;\n flex: 0 0 25%;\n max-width: 25%;\n }\n .col-lg-4 {\n -ms-flex: 0 0 33.333333%;\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n }\n .col-lg-5 {\n -ms-flex: 0 0 41.666667%;\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n }\n .col-lg-6 {\n -ms-flex: 0 0 50%;\n flex: 0 0 50%;\n max-width: 50%;\n }\n .col-lg-7 {\n -ms-flex: 0 0 58.333333%;\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n }\n .col-lg-8 {\n -ms-flex: 0 0 66.666667%;\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n }\n .col-lg-9 {\n -ms-flex: 0 0 75%;\n flex: 0 0 75%;\n max-width: 75%;\n }\n .col-lg-10 {\n -ms-flex: 0 0 83.333333%;\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n }\n .col-lg-11 {\n -ms-flex: 0 0 91.666667%;\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n }\n .col-lg-12 {\n -ms-flex: 0 0 100%;\n flex: 0 0 100%;\n max-width: 100%;\n }\n .order-lg-first {\n -ms-flex-order: -1;\n order: -1;\n }\n .order-lg-last {\n -ms-flex-order: 13;\n order: 13;\n }\n .order-lg-0 {\n -ms-flex-order: 0;\n order: 0;\n }\n .order-lg-1 {\n -ms-flex-order: 1;\n order: 1;\n }\n .order-lg-2 {\n -ms-flex-order: 2;\n order: 2;\n }\n .order-lg-3 {\n -ms-flex-order: 3;\n order: 3;\n }\n .order-lg-4 {\n -ms-flex-order: 4;\n order: 4;\n }\n .order-lg-5 {\n -ms-flex-order: 5;\n order: 5;\n }\n .order-lg-6 {\n -ms-flex-order: 6;\n order: 6;\n }\n .order-lg-7 {\n -ms-flex-order: 7;\n order: 7;\n }\n .order-lg-8 {\n -ms-flex-order: 8;\n order: 8;\n }\n .order-lg-9 {\n -ms-flex-order: 9;\n order: 9;\n }\n .order-lg-10 {\n -ms-flex-order: 10;\n order: 10;\n }\n .order-lg-11 {\n -ms-flex-order: 11;\n order: 11;\n }\n .order-lg-12 {\n -ms-flex-order: 12;\n order: 12;\n }\n .offset-lg-0 {\n margin-left: 0;\n }\n .offset-lg-1 {\n margin-left: 8.333333%;\n }\n .offset-lg-2 {\n margin-left: 16.666667%;\n }\n .offset-lg-3 {\n margin-left: 25%;\n }\n .offset-lg-4 {\n margin-left: 33.333333%;\n }\n .offset-lg-5 {\n margin-left: 41.666667%;\n }\n .offset-lg-6 {\n margin-left: 50%;\n }\n .offset-lg-7 {\n margin-left: 58.333333%;\n }\n .offset-lg-8 {\n margin-left: 66.666667%;\n }\n .offset-lg-9 {\n margin-left: 75%;\n }\n .offset-lg-10 {\n margin-left: 83.333333%;\n }\n .offset-lg-11 {\n margin-left: 91.666667%;\n }\n}\n\n@media (min-width: 1200px) {\n .col-xl {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n -ms-flex-positive: 1;\n flex-grow: 1;\n max-width: 100%;\n }\n .col-xl-auto {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n width: auto;\n max-width: 100%;\n }\n .col-xl-1 {\n -ms-flex: 0 0 8.333333%;\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n }\n .col-xl-2 {\n -ms-flex: 0 0 16.666667%;\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n }\n .col-xl-3 {\n -ms-flex: 0 0 25%;\n flex: 0 0 25%;\n max-width: 25%;\n }\n .col-xl-4 {\n -ms-flex: 0 0 33.333333%;\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n }\n .col-xl-5 {\n -ms-flex: 0 0 41.666667%;\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n }\n .col-xl-6 {\n -ms-flex: 0 0 50%;\n flex: 0 0 50%;\n max-width: 50%;\n }\n .col-xl-7 {\n -ms-flex: 0 0 58.333333%;\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n }\n .col-xl-8 {\n -ms-flex: 0 0 66.666667%;\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n }\n .col-xl-9 {\n -ms-flex: 0 0 75%;\n flex: 0 0 75%;\n max-width: 75%;\n }\n .col-xl-10 {\n -ms-flex: 0 0 83.333333%;\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n }\n .col-xl-11 {\n -ms-flex: 0 0 91.666667%;\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n }\n .col-xl-12 {\n -ms-flex: 0 0 100%;\n flex: 0 0 100%;\n max-width: 100%;\n }\n .order-xl-first {\n -ms-flex-order: -1;\n order: -1;\n }\n .order-xl-last {\n -ms-flex-order: 13;\n order: 13;\n }\n .order-xl-0 {\n -ms-flex-order: 0;\n order: 0;\n }\n .order-xl-1 {\n -ms-flex-order: 1;\n order: 1;\n }\n .order-xl-2 {\n -ms-flex-order: 2;\n order: 2;\n }\n .order-xl-3 {\n -ms-flex-order: 3;\n order: 3;\n }\n .order-xl-4 {\n -ms-flex-order: 4;\n order: 4;\n }\n .order-xl-5 {\n -ms-flex-order: 5;\n order: 5;\n }\n .order-xl-6 {\n -ms-flex-order: 6;\n order: 6;\n }\n .order-xl-7 {\n -ms-flex-order: 7;\n order: 7;\n }\n .order-xl-8 {\n -ms-flex-order: 8;\n order: 8;\n }\n .order-xl-9 {\n -ms-flex-order: 9;\n order: 9;\n }\n .order-xl-10 {\n -ms-flex-order: 10;\n order: 10;\n }\n .order-xl-11 {\n -ms-flex-order: 11;\n order: 11;\n }\n .order-xl-12 {\n -ms-flex-order: 12;\n order: 12;\n }\n .offset-xl-0 {\n margin-left: 0;\n }\n .offset-xl-1 {\n margin-left: 8.333333%;\n }\n .offset-xl-2 {\n margin-left: 16.666667%;\n }\n .offset-xl-3 {\n margin-left: 25%;\n }\n .offset-xl-4 {\n margin-left: 33.333333%;\n }\n .offset-xl-5 {\n margin-left: 41.666667%;\n }\n .offset-xl-6 {\n margin-left: 50%;\n }\n .offset-xl-7 {\n margin-left: 58.333333%;\n }\n .offset-xl-8 {\n margin-left: 66.666667%;\n }\n .offset-xl-9 {\n margin-left: 75%;\n }\n .offset-xl-10 {\n margin-left: 83.333333%;\n }\n .offset-xl-11 {\n margin-left: 91.666667%;\n }\n}\n\n.table {\n width: 100%;\n margin-bottom: 1rem;\n color: #212529;\n}\n\n.table th,\n.table td {\n padding: 0.75rem;\n vertical-align: top;\n border-top: 1px solid #dee2e6;\n}\n\n.table thead th {\n vertical-align: bottom;\n border-bottom: 2px solid #dee2e6;\n}\n\n.table tbody + tbody {\n border-top: 2px solid #dee2e6;\n}\n\n.table-sm th,\n.table-sm td {\n padding: 0.3rem;\n}\n\n.table-bordered {\n border: 1px solid #dee2e6;\n}\n\n.table-bordered th,\n.table-bordered td {\n border: 1px solid #dee2e6;\n}\n\n.table-bordered thead th,\n.table-bordered thead td {\n border-bottom-width: 2px;\n}\n\n.table-borderless th,\n.table-borderless td,\n.table-borderless thead th,\n.table-borderless tbody + tbody {\n border: 0;\n}\n\n.table-striped tbody tr:nth-of-type(odd) {\n background-color: rgba(0, 0, 0, 0.05);\n}\n\n.table-hover tbody tr:hover {\n color: #212529;\n background-color: rgba(0, 0, 0, 0.075);\n}\n\n.table-primary,\n.table-primary > th,\n.table-primary > td {\n background-color: #b8daff;\n}\n\n.table-primary th,\n.table-primary td,\n.table-primary thead th,\n.table-primary tbody + tbody {\n border-color: #7abaff;\n}\n\n.table-hover .table-primary:hover {\n background-color: #9fcdff;\n}\n\n.table-hover .table-primary:hover > td,\n.table-hover .table-primary:hover > th {\n background-color: #9fcdff;\n}\n\n.table-secondary,\n.table-secondary > th,\n.table-secondary > td {\n background-color: #d6d8db;\n}\n\n.table-secondary th,\n.table-secondary td,\n.table-secondary thead th,\n.table-secondary tbody + tbody {\n border-color: #b3b7bb;\n}\n\n.table-hover .table-secondary:hover {\n background-color: #c8cbcf;\n}\n\n.table-hover .table-secondary:hover > td,\n.table-hover .table-secondary:hover > th {\n background-color: #c8cbcf;\n}\n\n.table-success,\n.table-success > th,\n.table-success > td {\n background-color: #c3e6cb;\n}\n\n.table-success th,\n.table-success td,\n.table-success thead th,\n.table-success tbody + tbody {\n border-color: #8fd19e;\n}\n\n.table-hover .table-success:hover {\n background-color: #b1dfbb;\n}\n\n.table-hover .table-success:hover > td,\n.table-hover .table-success:hover > th {\n background-color: #b1dfbb;\n}\n\n.table-info,\n.table-info > th,\n.table-info > td {\n background-color: #bee5eb;\n}\n\n.table-info th,\n.table-info td,\n.table-info thead th,\n.table-info tbody + tbody {\n border-color: #86cfda;\n}\n\n.table-hover .table-info:hover {\n background-color: #abdde5;\n}\n\n.table-hover .table-info:hover > td,\n.table-hover .table-info:hover > th {\n background-color: #abdde5;\n}\n\n.table-warning,\n.table-warning > th,\n.table-warning > td {\n background-color: #ffeeba;\n}\n\n.table-warning th,\n.table-warning td,\n.table-warning thead th,\n.table-warning tbody + tbody {\n border-color: #ffdf7e;\n}\n\n.table-hover .table-warning:hover {\n background-color: #ffe8a1;\n}\n\n.table-hover .table-warning:hover > td,\n.table-hover .table-warning:hover > th {\n background-color: #ffe8a1;\n}\n\n.table-danger,\n.table-danger > th,\n.table-danger > td {\n background-color: #f5c6cb;\n}\n\n.table-danger th,\n.table-danger td,\n.table-danger thead th,\n.table-danger tbody + tbody {\n border-color: #ed969e;\n}\n\n.table-hover .table-danger:hover {\n background-color: #f1b0b7;\n}\n\n.table-hover .table-danger:hover > td,\n.table-hover .table-danger:hover > th {\n background-color: #f1b0b7;\n}\n\n.table-light,\n.table-light > th,\n.table-light > td {\n background-color: #fdfdfe;\n}\n\n.table-light th,\n.table-light td,\n.table-light thead th,\n.table-light tbody + tbody {\n border-color: #fbfcfc;\n}\n\n.table-hover .table-light:hover {\n background-color: #ececf6;\n}\n\n.table-hover .table-light:hover > td,\n.table-hover .table-light:hover > th {\n background-color: #ececf6;\n}\n\n.table-dark,\n.table-dark > th,\n.table-dark > td {\n background-color: #c6c8ca;\n}\n\n.table-dark th,\n.table-dark td,\n.table-dark thead th,\n.table-dark tbody + tbody {\n border-color: #95999c;\n}\n\n.table-hover .table-dark:hover {\n background-color: #b9bbbe;\n}\n\n.table-hover .table-dark:hover > td,\n.table-hover .table-dark:hover > th {\n background-color: #b9bbbe;\n}\n\n.table-active,\n.table-active > th,\n.table-active > td {\n background-color: rgba(0, 0, 0, 0.075);\n}\n\n.table-hover .table-active:hover {\n background-color: rgba(0, 0, 0, 0.075);\n}\n\n.table-hover .table-active:hover > td,\n.table-hover .table-active:hover > th {\n background-color: rgba(0, 0, 0, 0.075);\n}\n\n.table .thead-dark th {\n color: #fff;\n background-color: #343a40;\n border-color: #454d55;\n}\n\n.table .thead-light th {\n color: #495057;\n background-color: #e9ecef;\n border-color: #dee2e6;\n}\n\n.table-dark {\n color: #fff;\n background-color: #343a40;\n}\n\n.table-dark th,\n.table-dark td,\n.table-dark thead th {\n border-color: #454d55;\n}\n\n.table-dark.table-bordered {\n border: 0;\n}\n\n.table-dark.table-striped tbody tr:nth-of-type(odd) {\n background-color: rgba(255, 255, 255, 0.05);\n}\n\n.table-dark.table-hover tbody tr:hover {\n color: #fff;\n background-color: rgba(255, 255, 255, 0.075);\n}\n\n@media (max-width: 575.98px) {\n .table-responsive-sm {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n .table-responsive-sm > .table-bordered {\n border: 0;\n }\n}\n\n@media (max-width: 767.98px) {\n .table-responsive-md {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n .table-responsive-md > .table-bordered {\n border: 0;\n }\n}\n\n@media (max-width: 991.98px) {\n .table-responsive-lg {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n .table-responsive-lg > .table-bordered {\n border: 0;\n }\n}\n\n@media (max-width: 1199.98px) {\n .table-responsive-xl {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n .table-responsive-xl > .table-bordered {\n border: 0;\n }\n}\n\n.table-responsive {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n}\n\n.table-responsive > .table-bordered {\n border: 0;\n}\n\n.form-control {\n display: block;\n width: 100%;\n height: calc(1.5em + 0.75rem + 2px);\n padding: 0.375rem 0.75rem;\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #495057;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .form-control {\n transition: none;\n }\n}\n\n.form-control::-ms-expand {\n background-color: transparent;\n border: 0;\n}\n\n.form-control:focus {\n color: #495057;\n background-color: #fff;\n border-color: #80bdff;\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.form-control::-webkit-input-placeholder {\n color: #6c757d;\n opacity: 1;\n}\n\n.form-control::-moz-placeholder {\n color: #6c757d;\n opacity: 1;\n}\n\n.form-control:-ms-input-placeholder {\n color: #6c757d;\n opacity: 1;\n}\n\n.form-control::-ms-input-placeholder {\n color: #6c757d;\n opacity: 1;\n}\n\n.form-control::placeholder {\n color: #6c757d;\n opacity: 1;\n}\n\n.form-control:disabled, .form-control[readonly] {\n background-color: #e9ecef;\n opacity: 1;\n}\n\nselect.form-control:focus::-ms-value {\n color: #495057;\n background-color: #fff;\n}\n\n.form-control-file,\n.form-control-range {\n display: block;\n width: 100%;\n}\n\n.col-form-label {\n padding-top: calc(0.375rem + 1px);\n padding-bottom: calc(0.375rem + 1px);\n margin-bottom: 0;\n font-size: inherit;\n line-height: 1.5;\n}\n\n.col-form-label-lg {\n padding-top: calc(0.5rem + 1px);\n padding-bottom: calc(0.5rem + 1px);\n font-size: 1.25rem;\n line-height: 1.5;\n}\n\n.col-form-label-sm {\n padding-top: calc(0.25rem + 1px);\n padding-bottom: calc(0.25rem + 1px);\n font-size: 0.875rem;\n line-height: 1.5;\n}\n\n.form-control-plaintext {\n display: block;\n width: 100%;\n padding-top: 0.375rem;\n padding-bottom: 0.375rem;\n margin-bottom: 0;\n line-height: 1.5;\n color: #212529;\n background-color: transparent;\n border: solid transparent;\n border-width: 1px 0;\n}\n\n.form-control-plaintext.form-control-sm, .form-control-plaintext.form-control-lg {\n padding-right: 0;\n padding-left: 0;\n}\n\n.form-control-sm {\n height: calc(1.5em + 0.5rem + 2px);\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n line-height: 1.5;\n border-radius: 0.2rem;\n}\n\n.form-control-lg {\n height: calc(1.5em + 1rem + 2px);\n padding: 0.5rem 1rem;\n font-size: 1.25rem;\n line-height: 1.5;\n border-radius: 0.3rem;\n}\n\nselect.form-control[size], select.form-control[multiple] {\n height: auto;\n}\n\ntextarea.form-control {\n height: auto;\n}\n\n.form-group {\n margin-bottom: 1rem;\n}\n\n.form-text {\n display: block;\n margin-top: 0.25rem;\n}\n\n.form-row {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-wrap: wrap;\n flex-wrap: wrap;\n margin-right: -5px;\n margin-left: -5px;\n}\n\n.form-row > .col,\n.form-row > [class*=\"col-\"] {\n padding-right: 5px;\n padding-left: 5px;\n}\n\n.form-check {\n position: relative;\n display: block;\n padding-left: 1.25rem;\n}\n\n.form-check-input {\n position: absolute;\n margin-top: 0.3rem;\n margin-left: -1.25rem;\n}\n\n.form-check-input:disabled ~ .form-check-label {\n color: #6c757d;\n}\n\n.form-check-label {\n margin-bottom: 0;\n}\n\n.form-check-inline {\n display: -ms-inline-flexbox;\n display: inline-flex;\n -ms-flex-align: center;\n align-items: center;\n padding-left: 0;\n margin-right: 0.75rem;\n}\n\n.form-check-inline .form-check-input {\n position: static;\n margin-top: 0;\n margin-right: 0.3125rem;\n margin-left: 0;\n}\n\n.valid-feedback {\n display: none;\n width: 100%;\n margin-top: 0.25rem;\n font-size: 80%;\n color: #28a745;\n}\n\n.valid-tooltip {\n position: absolute;\n top: 100%;\n z-index: 5;\n display: none;\n max-width: 100%;\n padding: 0.25rem 0.5rem;\n margin-top: .1rem;\n font-size: 0.875rem;\n line-height: 1.5;\n color: #fff;\n background-color: rgba(40, 167, 69, 0.9);\n border-radius: 0.25rem;\n}\n\n.was-validated .form-control:valid, .form-control.is-valid {\n border-color: #28a745;\n padding-right: calc(1.5em + 0.75rem);\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e\");\n background-repeat: no-repeat;\n background-position: center right calc(0.375em + 0.1875rem);\n background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n\n.was-validated .form-control:valid:focus, .form-control.is-valid:focus {\n border-color: #28a745;\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);\n}\n\n.was-validated .form-control:valid ~ .valid-feedback,\n.was-validated .form-control:valid ~ .valid-tooltip, .form-control.is-valid ~ .valid-feedback,\n.form-control.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated textarea.form-control:valid, textarea.form-control.is-valid {\n padding-right: calc(1.5em + 0.75rem);\n background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem);\n}\n\n.was-validated .custom-select:valid, .custom-select.is-valid {\n border-color: #28a745;\n padding-right: calc((1em + 0.75rem) * 3 / 4 + 1.75rem);\n background: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e\") no-repeat right 0.75rem center/8px 10px, url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e\") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n\n.was-validated .custom-select:valid:focus, .custom-select.is-valid:focus {\n border-color: #28a745;\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);\n}\n\n.was-validated .custom-select:valid ~ .valid-feedback,\n.was-validated .custom-select:valid ~ .valid-tooltip, .custom-select.is-valid ~ .valid-feedback,\n.custom-select.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .form-control-file:valid ~ .valid-feedback,\n.was-validated .form-control-file:valid ~ .valid-tooltip, .form-control-file.is-valid ~ .valid-feedback,\n.form-control-file.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label {\n color: #28a745;\n}\n\n.was-validated .form-check-input:valid ~ .valid-feedback,\n.was-validated .form-check-input:valid ~ .valid-tooltip, .form-check-input.is-valid ~ .valid-feedback,\n.form-check-input.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .custom-control-input:valid ~ .custom-control-label, .custom-control-input.is-valid ~ .custom-control-label {\n color: #28a745;\n}\n\n.was-validated .custom-control-input:valid ~ .custom-control-label::before, .custom-control-input.is-valid ~ .custom-control-label::before {\n border-color: #28a745;\n}\n\n.was-validated .custom-control-input:valid ~ .valid-feedback,\n.was-validated .custom-control-input:valid ~ .valid-tooltip, .custom-control-input.is-valid ~ .valid-feedback,\n.custom-control-input.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .custom-control-input:valid:checked ~ .custom-control-label::before, .custom-control-input.is-valid:checked ~ .custom-control-label::before {\n border-color: #34ce57;\n background-color: #34ce57;\n}\n\n.was-validated .custom-control-input:valid:focus ~ .custom-control-label::before, .custom-control-input.is-valid:focus ~ .custom-control-label::before {\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);\n}\n\n.was-validated .custom-control-input:valid:focus:not(:checked) ~ .custom-control-label::before, .custom-control-input.is-valid:focus:not(:checked) ~ .custom-control-label::before {\n border-color: #28a745;\n}\n\n.was-validated .custom-file-input:valid ~ .custom-file-label, .custom-file-input.is-valid ~ .custom-file-label {\n border-color: #28a745;\n}\n\n.was-validated .custom-file-input:valid ~ .valid-feedback,\n.was-validated .custom-file-input:valid ~ .valid-tooltip, .custom-file-input.is-valid ~ .valid-feedback,\n.custom-file-input.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .custom-file-input:valid:focus ~ .custom-file-label, .custom-file-input.is-valid:focus ~ .custom-file-label {\n border-color: #28a745;\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);\n}\n\n.invalid-feedback {\n display: none;\n width: 100%;\n margin-top: 0.25rem;\n font-size: 80%;\n color: #dc3545;\n}\n\n.invalid-tooltip {\n position: absolute;\n top: 100%;\n z-index: 5;\n display: none;\n max-width: 100%;\n padding: 0.25rem 0.5rem;\n margin-top: .1rem;\n font-size: 0.875rem;\n line-height: 1.5;\n color: #fff;\n background-color: rgba(220, 53, 69, 0.9);\n border-radius: 0.25rem;\n}\n\n.was-validated .form-control:invalid, .form-control.is-invalid {\n border-color: #dc3545;\n padding-right: calc(1.5em + 0.75rem);\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23dc3545' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23dc3545' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E\");\n background-repeat: no-repeat;\n background-position: center right calc(0.375em + 0.1875rem);\n background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n\n.was-validated .form-control:invalid:focus, .form-control.is-invalid:focus {\n border-color: #dc3545;\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);\n}\n\n.was-validated .form-control:invalid ~ .invalid-feedback,\n.was-validated .form-control:invalid ~ .invalid-tooltip, .form-control.is-invalid ~ .invalid-feedback,\n.form-control.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated textarea.form-control:invalid, textarea.form-control.is-invalid {\n padding-right: calc(1.5em + 0.75rem);\n background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem);\n}\n\n.was-validated .custom-select:invalid, .custom-select.is-invalid {\n border-color: #dc3545;\n padding-right: calc((1em + 0.75rem) * 3 / 4 + 1.75rem);\n background: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e\") no-repeat right 0.75rem center/8px 10px, url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23dc3545' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23dc3545' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E\") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n\n.was-validated .custom-select:invalid:focus, .custom-select.is-invalid:focus {\n border-color: #dc3545;\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);\n}\n\n.was-validated .custom-select:invalid ~ .invalid-feedback,\n.was-validated .custom-select:invalid ~ .invalid-tooltip, .custom-select.is-invalid ~ .invalid-feedback,\n.custom-select.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .form-control-file:invalid ~ .invalid-feedback,\n.was-validated .form-control-file:invalid ~ .invalid-tooltip, .form-control-file.is-invalid ~ .invalid-feedback,\n.form-control-file.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label {\n color: #dc3545;\n}\n\n.was-validated .form-check-input:invalid ~ .invalid-feedback,\n.was-validated .form-check-input:invalid ~ .invalid-tooltip, .form-check-input.is-invalid ~ .invalid-feedback,\n.form-check-input.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .custom-control-input:invalid ~ .custom-control-label, .custom-control-input.is-invalid ~ .custom-control-label {\n color: #dc3545;\n}\n\n.was-validated .custom-control-input:invalid ~ .custom-control-label::before, .custom-control-input.is-invalid ~ .custom-control-label::before {\n border-color: #dc3545;\n}\n\n.was-validated .custom-control-input:invalid ~ .invalid-feedback,\n.was-validated .custom-control-input:invalid ~ .invalid-tooltip, .custom-control-input.is-invalid ~ .invalid-feedback,\n.custom-control-input.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .custom-control-input:invalid:checked ~ .custom-control-label::before, .custom-control-input.is-invalid:checked ~ .custom-control-label::before {\n border-color: #e4606d;\n background-color: #e4606d;\n}\n\n.was-validated .custom-control-input:invalid:focus ~ .custom-control-label::before, .custom-control-input.is-invalid:focus ~ .custom-control-label::before {\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);\n}\n\n.was-validated .custom-control-input:invalid:focus:not(:checked) ~ .custom-control-label::before, .custom-control-input.is-invalid:focus:not(:checked) ~ .custom-control-label::before {\n border-color: #dc3545;\n}\n\n.was-validated .custom-file-input:invalid ~ .custom-file-label, .custom-file-input.is-invalid ~ .custom-file-label {\n border-color: #dc3545;\n}\n\n.was-validated .custom-file-input:invalid ~ .invalid-feedback,\n.was-validated .custom-file-input:invalid ~ .invalid-tooltip, .custom-file-input.is-invalid ~ .invalid-feedback,\n.custom-file-input.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .custom-file-input:invalid:focus ~ .custom-file-label, .custom-file-input.is-invalid:focus ~ .custom-file-label {\n border-color: #dc3545;\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);\n}\n\n.form-inline {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-flow: row wrap;\n flex-flow: row wrap;\n -ms-flex-align: center;\n align-items: center;\n}\n\n.form-inline .form-check {\n width: 100%;\n}\n\n@media (min-width: 576px) {\n .form-inline label {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-align: center;\n align-items: center;\n -ms-flex-pack: center;\n justify-content: center;\n margin-bottom: 0;\n }\n .form-inline .form-group {\n display: -ms-flexbox;\n display: flex;\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n -ms-flex-flow: row wrap;\n flex-flow: row wrap;\n -ms-flex-align: center;\n align-items: center;\n margin-bottom: 0;\n }\n .form-inline .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .form-inline .form-control-plaintext {\n display: inline-block;\n }\n .form-inline .input-group,\n .form-inline .custom-select {\n width: auto;\n }\n .form-inline .form-check {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-align: center;\n align-items: center;\n -ms-flex-pack: center;\n justify-content: center;\n width: auto;\n padding-left: 0;\n }\n .form-inline .form-check-input {\n position: relative;\n -ms-flex-negative: 0;\n flex-shrink: 0;\n margin-top: 0;\n margin-right: 0.25rem;\n margin-left: 0;\n }\n .form-inline .custom-control {\n -ms-flex-align: center;\n align-items: center;\n -ms-flex-pack: center;\n justify-content: center;\n }\n .form-inline .custom-control-label {\n margin-bottom: 0;\n }\n}\n\n.btn {\n display: inline-block;\n font-weight: 400;\n color: #212529;\n text-align: center;\n vertical-align: middle;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n background-color: transparent;\n border: 1px solid transparent;\n padding: 0.375rem 0.75rem;\n font-size: 1rem;\n line-height: 1.5;\n border-radius: 0.25rem;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .btn {\n transition: none;\n }\n}\n\n.btn:hover {\n color: #212529;\n text-decoration: none;\n}\n\n.btn:focus, .btn.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.btn.disabled, .btn:disabled {\n opacity: 0.65;\n}\n\na.btn.disabled,\nfieldset:disabled a.btn {\n pointer-events: none;\n}\n\n.btn-primary {\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.btn-primary:hover {\n color: #fff;\n background-color: #0069d9;\n border-color: #0062cc;\n}\n\n.btn-primary:focus, .btn-primary.focus {\n box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5);\n}\n\n.btn-primary.disabled, .btn-primary:disabled {\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.btn-primary:not(:disabled):not(.disabled):active, .btn-primary:not(:disabled):not(.disabled).active,\n.show > .btn-primary.dropdown-toggle {\n color: #fff;\n background-color: #0062cc;\n border-color: #005cbf;\n}\n\n.btn-primary:not(:disabled):not(.disabled):active:focus, .btn-primary:not(:disabled):not(.disabled).active:focus,\n.show > .btn-primary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5);\n}\n\n.btn-secondary {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-secondary:hover {\n color: #fff;\n background-color: #5a6268;\n border-color: #545b62;\n}\n\n.btn-secondary:focus, .btn-secondary.focus {\n box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5);\n}\n\n.btn-secondary.disabled, .btn-secondary:disabled {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-secondary:not(:disabled):not(.disabled):active, .btn-secondary:not(:disabled):not(.disabled).active,\n.show > .btn-secondary.dropdown-toggle {\n color: #fff;\n background-color: #545b62;\n border-color: #4e555b;\n}\n\n.btn-secondary:not(:disabled):not(.disabled):active:focus, .btn-secondary:not(:disabled):not(.disabled).active:focus,\n.show > .btn-secondary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5);\n}\n\n.btn-success {\n color: #fff;\n background-color: #28a745;\n border-color: #28a745;\n}\n\n.btn-success:hover {\n color: #fff;\n background-color: #218838;\n border-color: #1e7e34;\n}\n\n.btn-success:focus, .btn-success.focus {\n box-shadow: 0 0 0 0.2rem rgba(72, 180, 97, 0.5);\n}\n\n.btn-success.disabled, .btn-success:disabled {\n color: #fff;\n background-color: #28a745;\n border-color: #28a745;\n}\n\n.btn-success:not(:disabled):not(.disabled):active, .btn-success:not(:disabled):not(.disabled).active,\n.show > .btn-success.dropdown-toggle {\n color: #fff;\n background-color: #1e7e34;\n border-color: #1c7430;\n}\n\n.btn-success:not(:disabled):not(.disabled):active:focus, .btn-success:not(:disabled):not(.disabled).active:focus,\n.show > .btn-success.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(72, 180, 97, 0.5);\n}\n\n.btn-info {\n color: #fff;\n background-color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-info:hover {\n color: #fff;\n background-color: #138496;\n border-color: #117a8b;\n}\n\n.btn-info:focus, .btn-info.focus {\n box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5);\n}\n\n.btn-info.disabled, .btn-info:disabled {\n color: #fff;\n background-color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-info:not(:disabled):not(.disabled):active, .btn-info:not(:disabled):not(.disabled).active,\n.show > .btn-info.dropdown-toggle {\n color: #fff;\n background-color: #117a8b;\n border-color: #10707f;\n}\n\n.btn-info:not(:disabled):not(.disabled):active:focus, .btn-info:not(:disabled):not(.disabled).active:focus,\n.show > .btn-info.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5);\n}\n\n.btn-warning {\n color: #212529;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-warning:hover {\n color: #212529;\n background-color: #e0a800;\n border-color: #d39e00;\n}\n\n.btn-warning:focus, .btn-warning.focus {\n box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5);\n}\n\n.btn-warning.disabled, .btn-warning:disabled {\n color: #212529;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-warning:not(:disabled):not(.disabled):active, .btn-warning:not(:disabled):not(.disabled).active,\n.show > .btn-warning.dropdown-toggle {\n color: #212529;\n background-color: #d39e00;\n border-color: #c69500;\n}\n\n.btn-warning:not(:disabled):not(.disabled):active:focus, .btn-warning:not(:disabled):not(.disabled).active:focus,\n.show > .btn-warning.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5);\n}\n\n.btn-danger {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-danger:hover {\n color: #fff;\n background-color: #c82333;\n border-color: #bd2130;\n}\n\n.btn-danger:focus, .btn-danger.focus {\n box-shadow: 0 0 0 0.2rem rgba(225, 83, 97, 0.5);\n}\n\n.btn-danger.disabled, .btn-danger:disabled {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-danger:not(:disabled):not(.disabled):active, .btn-danger:not(:disabled):not(.disabled).active,\n.show > .btn-danger.dropdown-toggle {\n color: #fff;\n background-color: #bd2130;\n border-color: #b21f2d;\n}\n\n.btn-danger:not(:disabled):not(.disabled):active:focus, .btn-danger:not(:disabled):not(.disabled).active:focus,\n.show > .btn-danger.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(225, 83, 97, 0.5);\n}\n\n.btn-light {\n color: #212529;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-light:hover {\n color: #212529;\n background-color: #e2e6ea;\n border-color: #dae0e5;\n}\n\n.btn-light:focus, .btn-light.focus {\n box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5);\n}\n\n.btn-light.disabled, .btn-light:disabled {\n color: #212529;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-light:not(:disabled):not(.disabled):active, .btn-light:not(:disabled):not(.disabled).active,\n.show > .btn-light.dropdown-toggle {\n color: #212529;\n background-color: #dae0e5;\n border-color: #d3d9df;\n}\n\n.btn-light:not(:disabled):not(.disabled):active:focus, .btn-light:not(:disabled):not(.disabled).active:focus,\n.show > .btn-light.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5);\n}\n\n.btn-dark {\n color: #fff;\n background-color: #343a40;\n border-color: #343a40;\n}\n\n.btn-dark:hover {\n color: #fff;\n background-color: #23272b;\n border-color: #1d2124;\n}\n\n.btn-dark:focus, .btn-dark.focus {\n box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5);\n}\n\n.btn-dark.disabled, .btn-dark:disabled {\n color: #fff;\n background-color: #343a40;\n border-color: #343a40;\n}\n\n.btn-dark:not(:disabled):not(.disabled):active, .btn-dark:not(:disabled):not(.disabled).active,\n.show > .btn-dark.dropdown-toggle {\n color: #fff;\n background-color: #1d2124;\n border-color: #171a1d;\n}\n\n.btn-dark:not(:disabled):not(.disabled):active:focus, .btn-dark:not(:disabled):not(.disabled).active:focus,\n.show > .btn-dark.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5);\n}\n\n.btn-outline-primary {\n color: #007bff;\n border-color: #007bff;\n}\n\n.btn-outline-primary:hover {\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.btn-outline-primary:focus, .btn-outline-primary.focus {\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);\n}\n\n.btn-outline-primary.disabled, .btn-outline-primary:disabled {\n color: #007bff;\n background-color: transparent;\n}\n\n.btn-outline-primary:not(:disabled):not(.disabled):active, .btn-outline-primary:not(:disabled):not(.disabled).active,\n.show > .btn-outline-primary.dropdown-toggle {\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.btn-outline-primary:not(:disabled):not(.disabled):active:focus, .btn-outline-primary:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-primary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);\n}\n\n.btn-outline-secondary {\n color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-outline-secondary:hover {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-outline-secondary:focus, .btn-outline-secondary.focus {\n box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);\n}\n\n.btn-outline-secondary.disabled, .btn-outline-secondary:disabled {\n color: #6c757d;\n background-color: transparent;\n}\n\n.btn-outline-secondary:not(:disabled):not(.disabled):active, .btn-outline-secondary:not(:disabled):not(.disabled).active,\n.show > .btn-outline-secondary.dropdown-toggle {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-outline-secondary:not(:disabled):not(.disabled):active:focus, .btn-outline-secondary:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-secondary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);\n}\n\n.btn-outline-success {\n color: #28a745;\n border-color: #28a745;\n}\n\n.btn-outline-success:hover {\n color: #fff;\n background-color: #28a745;\n border-color: #28a745;\n}\n\n.btn-outline-success:focus, .btn-outline-success.focus {\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);\n}\n\n.btn-outline-success.disabled, .btn-outline-success:disabled {\n color: #28a745;\n background-color: transparent;\n}\n\n.btn-outline-success:not(:disabled):not(.disabled):active, .btn-outline-success:not(:disabled):not(.disabled).active,\n.show > .btn-outline-success.dropdown-toggle {\n color: #fff;\n background-color: #28a745;\n border-color: #28a745;\n}\n\n.btn-outline-success:not(:disabled):not(.disabled):active:focus, .btn-outline-success:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-success.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);\n}\n\n.btn-outline-info {\n color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-outline-info:hover {\n color: #fff;\n background-color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-outline-info:focus, .btn-outline-info.focus {\n box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);\n}\n\n.btn-outline-info.disabled, .btn-outline-info:disabled {\n color: #17a2b8;\n background-color: transparent;\n}\n\n.btn-outline-info:not(:disabled):not(.disabled):active, .btn-outline-info:not(:disabled):not(.disabled).active,\n.show > .btn-outline-info.dropdown-toggle {\n color: #fff;\n background-color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-outline-info:not(:disabled):not(.disabled):active:focus, .btn-outline-info:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-info.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);\n}\n\n.btn-outline-warning {\n color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-outline-warning:hover {\n color: #212529;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-outline-warning:focus, .btn-outline-warning.focus {\n box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);\n}\n\n.btn-outline-warning.disabled, .btn-outline-warning:disabled {\n color: #ffc107;\n background-color: transparent;\n}\n\n.btn-outline-warning:not(:disabled):not(.disabled):active, .btn-outline-warning:not(:disabled):not(.disabled).active,\n.show > .btn-outline-warning.dropdown-toggle {\n color: #212529;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-outline-warning:not(:disabled):not(.disabled):active:focus, .btn-outline-warning:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-warning.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);\n}\n\n.btn-outline-danger {\n color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-outline-danger:hover {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-outline-danger:focus, .btn-outline-danger.focus {\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);\n}\n\n.btn-outline-danger.disabled, .btn-outline-danger:disabled {\n color: #dc3545;\n background-color: transparent;\n}\n\n.btn-outline-danger:not(:disabled):not(.disabled):active, .btn-outline-danger:not(:disabled):not(.disabled).active,\n.show > .btn-outline-danger.dropdown-toggle {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-outline-danger:not(:disabled):not(.disabled):active:focus, .btn-outline-danger:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-danger.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);\n}\n\n.btn-outline-light {\n color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-outline-light:hover {\n color: #212529;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-outline-light:focus, .btn-outline-light.focus {\n box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);\n}\n\n.btn-outline-light.disabled, .btn-outline-light:disabled {\n color: #f8f9fa;\n background-color: transparent;\n}\n\n.btn-outline-light:not(:disabled):not(.disabled):active, .btn-outline-light:not(:disabled):not(.disabled).active,\n.show > .btn-outline-light.dropdown-toggle {\n color: #212529;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-outline-light:not(:disabled):not(.disabled):active:focus, .btn-outline-light:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-light.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);\n}\n\n.btn-outline-dark {\n color: #343a40;\n border-color: #343a40;\n}\n\n.btn-outline-dark:hover {\n color: #fff;\n background-color: #343a40;\n border-color: #343a40;\n}\n\n.btn-outline-dark:focus, .btn-outline-dark.focus {\n box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);\n}\n\n.btn-outline-dark.disabled, .btn-outline-dark:disabled {\n color: #343a40;\n background-color: transparent;\n}\n\n.btn-outline-dark:not(:disabled):not(.disabled):active, .btn-outline-dark:not(:disabled):not(.disabled).active,\n.show > .btn-outline-dark.dropdown-toggle {\n color: #fff;\n background-color: #343a40;\n border-color: #343a40;\n}\n\n.btn-outline-dark:not(:disabled):not(.disabled):active:focus, .btn-outline-dark:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-dark.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);\n}\n\n.btn-link {\n font-weight: 400;\n color: #007bff;\n text-decoration: none;\n}\n\n.btn-link:hover {\n color: #0056b3;\n text-decoration: underline;\n}\n\n.btn-link:focus, .btn-link.focus {\n text-decoration: underline;\n box-shadow: none;\n}\n\n.btn-link:disabled, .btn-link.disabled {\n color: #6c757d;\n pointer-events: none;\n}\n\n.btn-lg, .btn-group-lg > .btn {\n padding: 0.5rem 1rem;\n font-size: 1.25rem;\n line-height: 1.5;\n border-radius: 0.3rem;\n}\n\n.btn-sm, .btn-group-sm > .btn {\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n line-height: 1.5;\n border-radius: 0.2rem;\n}\n\n.btn-block {\n display: block;\n width: 100%;\n}\n\n.btn-block + .btn-block {\n margin-top: 0.5rem;\n}\n\ninput[type=\"submit\"].btn-block,\ninput[type=\"reset\"].btn-block,\ninput[type=\"button\"].btn-block {\n width: 100%;\n}\n\n.fade {\n transition: opacity 0.15s linear;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .fade {\n transition: none;\n }\n}\n\n.fade:not(.show) {\n opacity: 0;\n}\n\n.collapse:not(.show) {\n display: none;\n}\n\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n transition: height 0.35s ease;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .collapsing {\n transition: none;\n }\n}\n\n.dropup,\n.dropright,\n.dropdown,\n.dropleft {\n position: relative;\n}\n\n.dropdown-toggle {\n white-space: nowrap;\n}\n\n.dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid;\n border-right: 0.3em solid transparent;\n border-bottom: 0;\n border-left: 0.3em solid transparent;\n}\n\n.dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: 1000;\n display: none;\n float: left;\n min-width: 10rem;\n padding: 0.5rem 0;\n margin: 0.125rem 0 0;\n font-size: 1rem;\n color: #212529;\n text-align: left;\n list-style: none;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.15);\n border-radius: 0.25rem;\n}\n\n.dropdown-menu-left {\n right: auto;\n left: 0;\n}\n\n.dropdown-menu-right {\n right: 0;\n left: auto;\n}\n\n@media (min-width: 576px) {\n .dropdown-menu-sm-left {\n right: auto;\n left: 0;\n }\n .dropdown-menu-sm-right {\n right: 0;\n left: auto;\n }\n}\n\n@media (min-width: 768px) {\n .dropdown-menu-md-left {\n right: auto;\n left: 0;\n }\n .dropdown-menu-md-right {\n right: 0;\n left: auto;\n }\n}\n\n@media (min-width: 992px) {\n .dropdown-menu-lg-left {\n right: auto;\n left: 0;\n }\n .dropdown-menu-lg-right {\n right: 0;\n left: auto;\n }\n}\n\n@media (min-width: 1200px) {\n .dropdown-menu-xl-left {\n right: auto;\n left: 0;\n }\n .dropdown-menu-xl-right {\n right: 0;\n left: auto;\n }\n}\n\n.dropup .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-top: 0;\n margin-bottom: 0.125rem;\n}\n\n.dropup .dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0;\n border-right: 0.3em solid transparent;\n border-bottom: 0.3em solid;\n border-left: 0.3em solid transparent;\n}\n\n.dropup .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropright .dropdown-menu {\n top: 0;\n right: auto;\n left: 100%;\n margin-top: 0;\n margin-left: 0.125rem;\n}\n\n.dropright .dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid transparent;\n border-right: 0;\n border-bottom: 0.3em solid transparent;\n border-left: 0.3em solid;\n}\n\n.dropright .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropright .dropdown-toggle::after {\n vertical-align: 0;\n}\n\n.dropleft .dropdown-menu {\n top: 0;\n right: 100%;\n left: auto;\n margin-top: 0;\n margin-right: 0.125rem;\n}\n\n.dropleft .dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n}\n\n.dropleft .dropdown-toggle::after {\n display: none;\n}\n\n.dropleft .dropdown-toggle::before {\n display: inline-block;\n margin-right: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid transparent;\n border-right: 0.3em solid;\n border-bottom: 0.3em solid transparent;\n}\n\n.dropleft .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropleft .dropdown-toggle::before {\n vertical-align: 0;\n}\n\n.dropdown-menu[x-placement^=\"top\"], .dropdown-menu[x-placement^=\"right\"], .dropdown-menu[x-placement^=\"bottom\"], .dropdown-menu[x-placement^=\"left\"] {\n right: auto;\n bottom: auto;\n}\n\n.dropdown-divider {\n height: 0;\n margin: 0.5rem 0;\n overflow: hidden;\n border-top: 1px solid #e9ecef;\n}\n\n.dropdown-item {\n display: block;\n width: 100%;\n padding: 0.25rem 1.5rem;\n clear: both;\n font-weight: 400;\n color: #212529;\n text-align: inherit;\n white-space: nowrap;\n background-color: transparent;\n border: 0;\n}\n\n.dropdown-item:hover, .dropdown-item:focus {\n color: #16181b;\n text-decoration: none;\n background-color: #f8f9fa;\n}\n\n.dropdown-item.active, .dropdown-item:active {\n color: #fff;\n text-decoration: none;\n background-color: #007bff;\n}\n\n.dropdown-item.disabled, .dropdown-item:disabled {\n color: #6c757d;\n pointer-events: none;\n background-color: transparent;\n}\n\n.dropdown-menu.show {\n display: block;\n}\n\n.dropdown-header {\n display: block;\n padding: 0.5rem 1.5rem;\n margin-bottom: 0;\n font-size: 0.875rem;\n color: #6c757d;\n white-space: nowrap;\n}\n\n.dropdown-item-text {\n display: block;\n padding: 0.25rem 1.5rem;\n color: #212529;\n}\n\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: -ms-inline-flexbox;\n display: inline-flex;\n vertical-align: middle;\n}\n\n.btn-group > .btn,\n.btn-group-vertical > .btn {\n position: relative;\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n\n.btn-group > .btn:hover,\n.btn-group-vertical > .btn:hover {\n z-index: 1;\n}\n\n.btn-group > .btn:focus, .btn-group > .btn:active, .btn-group > .btn.active,\n.btn-group-vertical > .btn:focus,\n.btn-group-vertical > .btn:active,\n.btn-group-vertical > .btn.active {\n z-index: 1;\n}\n\n.btn-toolbar {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-wrap: wrap;\n flex-wrap: wrap;\n -ms-flex-pack: start;\n justify-content: flex-start;\n}\n\n.btn-toolbar .input-group {\n width: auto;\n}\n\n.btn-group > .btn:not(:first-child),\n.btn-group > .btn-group:not(:first-child) {\n margin-left: -1px;\n}\n\n.btn-group > .btn:not(:last-child):not(.dropdown-toggle),\n.btn-group > .btn-group:not(:last-child) > .btn {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n\n.btn-group > .btn:not(:first-child),\n.btn-group > .btn-group:not(:first-child) > .btn {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.dropdown-toggle-split {\n padding-right: 0.5625rem;\n padding-left: 0.5625rem;\n}\n\n.dropdown-toggle-split::after,\n.dropup .dropdown-toggle-split::after,\n.dropright .dropdown-toggle-split::after {\n margin-left: 0;\n}\n\n.dropleft .dropdown-toggle-split::before {\n margin-right: 0;\n}\n\n.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split {\n padding-right: 0.375rem;\n padding-left: 0.375rem;\n}\n\n.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split {\n padding-right: 0.75rem;\n padding-left: 0.75rem;\n}\n\n.btn-group-vertical {\n -ms-flex-direction: column;\n flex-direction: column;\n -ms-flex-align: start;\n align-items: flex-start;\n -ms-flex-pack: center;\n justify-content: center;\n}\n\n.btn-group-vertical > .btn,\n.btn-group-vertical > .btn-group {\n width: 100%;\n}\n\n.btn-group-vertical > .btn:not(:first-child),\n.btn-group-vertical > .btn-group:not(:first-child) {\n margin-top: -1px;\n}\n\n.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle),\n.btn-group-vertical > .btn-group:not(:last-child) > .btn {\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.btn-group-vertical > .btn:not(:first-child),\n.btn-group-vertical > .btn-group:not(:first-child) > .btn {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n\n.btn-group-toggle > .btn,\n.btn-group-toggle > .btn-group > .btn {\n margin-bottom: 0;\n}\n\n.btn-group-toggle > .btn input[type=\"radio\"],\n.btn-group-toggle > .btn input[type=\"checkbox\"],\n.btn-group-toggle > .btn-group > .btn input[type=\"radio\"],\n.btn-group-toggle > .btn-group > .btn input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n}\n\n.input-group {\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-wrap: wrap;\n flex-wrap: wrap;\n -ms-flex-align: stretch;\n align-items: stretch;\n width: 100%;\n}\n\n.input-group > .form-control,\n.input-group > .form-control-plaintext,\n.input-group > .custom-select,\n.input-group > .custom-file {\n position: relative;\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n width: 1%;\n margin-bottom: 0;\n}\n\n.input-group > .form-control + .form-control,\n.input-group > .form-control + .custom-select,\n.input-group > .form-control + .custom-file,\n.input-group > .form-control-plaintext + .form-control,\n.input-group > .form-control-plaintext + .custom-select,\n.input-group > .form-control-plaintext + .custom-file,\n.input-group > .custom-select + .form-control,\n.input-group > .custom-select + .custom-select,\n.input-group > .custom-select + .custom-file,\n.input-group > .custom-file + .form-control,\n.input-group > .custom-file + .custom-select,\n.input-group > .custom-file + .custom-file {\n margin-left: -1px;\n}\n\n.input-group > .form-control:focus,\n.input-group > .custom-select:focus,\n.input-group > .custom-file .custom-file-input:focus ~ .custom-file-label {\n z-index: 3;\n}\n\n.input-group > .custom-file .custom-file-input:focus {\n z-index: 4;\n}\n\n.input-group > .form-control:not(:last-child),\n.input-group > .custom-select:not(:last-child) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n\n.input-group > .form-control:not(:first-child),\n.input-group > .custom-select:not(:first-child) {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.input-group > .custom-file {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-align: center;\n align-items: center;\n}\n\n.input-group > .custom-file:not(:last-child) .custom-file-label,\n.input-group > .custom-file:not(:last-child) .custom-file-label::after {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n\n.input-group > .custom-file:not(:first-child) .custom-file-label {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.input-group-prepend,\n.input-group-append {\n display: -ms-flexbox;\n display: flex;\n}\n\n.input-group-prepend .btn,\n.input-group-append .btn {\n position: relative;\n z-index: 2;\n}\n\n.input-group-prepend .btn:focus,\n.input-group-append .btn:focus {\n z-index: 3;\n}\n\n.input-group-prepend .btn + .btn,\n.input-group-prepend .btn + .input-group-text,\n.input-group-prepend .input-group-text + .input-group-text,\n.input-group-prepend .input-group-text + .btn,\n.input-group-append .btn + .btn,\n.input-group-append .btn + .input-group-text,\n.input-group-append .input-group-text + .input-group-text,\n.input-group-append .input-group-text + .btn {\n margin-left: -1px;\n}\n\n.input-group-prepend {\n margin-right: -1px;\n}\n\n.input-group-append {\n margin-left: -1px;\n}\n\n.input-group-text {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-align: center;\n align-items: center;\n padding: 0.375rem 0.75rem;\n margin-bottom: 0;\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #495057;\n text-align: center;\n white-space: nowrap;\n background-color: #e9ecef;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n}\n\n.input-group-text input[type=\"radio\"],\n.input-group-text input[type=\"checkbox\"] {\n margin-top: 0;\n}\n\n.input-group-lg > .form-control:not(textarea),\n.input-group-lg > .custom-select {\n height: calc(1.5em + 1rem + 2px);\n}\n\n.input-group-lg > .form-control,\n.input-group-lg > .custom-select,\n.input-group-lg > .input-group-prepend > .input-group-text,\n.input-group-lg > .input-group-append > .input-group-text,\n.input-group-lg > .input-group-prepend > .btn,\n.input-group-lg > .input-group-append > .btn {\n padding: 0.5rem 1rem;\n font-size: 1.25rem;\n line-height: 1.5;\n border-radius: 0.3rem;\n}\n\n.input-group-sm > .form-control:not(textarea),\n.input-group-sm > .custom-select {\n height: calc(1.5em + 0.5rem + 2px);\n}\n\n.input-group-sm > .form-control,\n.input-group-sm > .custom-select,\n.input-group-sm > .input-group-prepend > .input-group-text,\n.input-group-sm > .input-group-append > .input-group-text,\n.input-group-sm > .input-group-prepend > .btn,\n.input-group-sm > .input-group-append > .btn {\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n line-height: 1.5;\n border-radius: 0.2rem;\n}\n\n.input-group-lg > .custom-select,\n.input-group-sm > .custom-select {\n padding-right: 1.75rem;\n}\n\n.input-group > .input-group-prepend > .btn,\n.input-group > .input-group-prepend > .input-group-text,\n.input-group > .input-group-append:not(:last-child) > .btn,\n.input-group > .input-group-append:not(:last-child) > .input-group-text,\n.input-group > .input-group-append:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group > .input-group-append:last-child > .input-group-text:not(:last-child) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n\n.input-group > .input-group-append > .btn,\n.input-group > .input-group-append > .input-group-text,\n.input-group > .input-group-prepend:not(:first-child) > .btn,\n.input-group > .input-group-prepend:not(:first-child) > .input-group-text,\n.input-group > .input-group-prepend:first-child > .btn:not(:first-child),\n.input-group > .input-group-prepend:first-child > .input-group-text:not(:first-child) {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.custom-control {\n position: relative;\n display: block;\n min-height: 1.5rem;\n padding-left: 1.5rem;\n}\n\n.custom-control-inline {\n display: -ms-inline-flexbox;\n display: inline-flex;\n margin-right: 1rem;\n}\n\n.custom-control-input {\n position: absolute;\n z-index: -1;\n opacity: 0;\n}\n\n.custom-control-input:checked ~ .custom-control-label::before {\n color: #fff;\n border-color: #007bff;\n background-color: #007bff;\n}\n\n.custom-control-input:focus ~ .custom-control-label::before {\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-control-input:focus:not(:checked) ~ .custom-control-label::before {\n border-color: #80bdff;\n}\n\n.custom-control-input:not(:disabled):active ~ .custom-control-label::before {\n color: #fff;\n background-color: #b3d7ff;\n border-color: #b3d7ff;\n}\n\n.custom-control-input:disabled ~ .custom-control-label {\n color: #6c757d;\n}\n\n.custom-control-input:disabled ~ .custom-control-label::before {\n background-color: #e9ecef;\n}\n\n.custom-control-label {\n position: relative;\n margin-bottom: 0;\n vertical-align: top;\n}\n\n.custom-control-label::before {\n position: absolute;\n top: 0.25rem;\n left: -1.5rem;\n display: block;\n width: 1rem;\n height: 1rem;\n pointer-events: none;\n content: \"\";\n background-color: #fff;\n border: #adb5bd solid 1px;\n}\n\n.custom-control-label::after {\n position: absolute;\n top: 0.25rem;\n left: -1.5rem;\n display: block;\n width: 1rem;\n height: 1rem;\n content: \"\";\n background: no-repeat 50% / 50% 50%;\n}\n\n.custom-checkbox .custom-control-label::before {\n border-radius: 0.25rem;\n}\n\n.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3e%3c/svg%3e\");\n}\n\n.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before {\n border-color: #007bff;\n background-color: #007bff;\n}\n\n.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::after {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e\");\n}\n\n.custom-checkbox .custom-control-input:disabled:checked ~ .custom-control-label::before {\n background-color: rgba(0, 123, 255, 0.5);\n}\n\n.custom-checkbox .custom-control-input:disabled:indeterminate ~ .custom-control-label::before {\n background-color: rgba(0, 123, 255, 0.5);\n}\n\n.custom-radio .custom-control-label::before {\n border-radius: 50%;\n}\n\n.custom-radio .custom-control-input:checked ~ .custom-control-label::after {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e\");\n}\n\n.custom-radio .custom-control-input:disabled:checked ~ .custom-control-label::before {\n background-color: rgba(0, 123, 255, 0.5);\n}\n\n.custom-switch {\n padding-left: 2.25rem;\n}\n\n.custom-switch .custom-control-label::before {\n left: -2.25rem;\n width: 1.75rem;\n pointer-events: all;\n border-radius: 0.5rem;\n}\n\n.custom-switch .custom-control-label::after {\n top: calc(0.25rem + 2px);\n left: calc(-2.25rem + 2px);\n width: calc(1rem - 4px);\n height: calc(1rem - 4px);\n background-color: #adb5bd;\n border-radius: 0.5rem;\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-transform 0.15s ease-in-out;\n transition: transform 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n transition: transform 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-transform 0.15s ease-in-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .custom-switch .custom-control-label::after {\n transition: none;\n }\n}\n\n.custom-switch .custom-control-input:checked ~ .custom-control-label::after {\n background-color: #fff;\n -webkit-transform: translateX(0.75rem);\n transform: translateX(0.75rem);\n}\n\n.custom-switch .custom-control-input:disabled:checked ~ .custom-control-label::before {\n background-color: rgba(0, 123, 255, 0.5);\n}\n\n.custom-select {\n display: inline-block;\n width: 100%;\n height: calc(1.5em + 0.75rem + 2px);\n padding: 0.375rem 1.75rem 0.375rem 0.75rem;\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #495057;\n vertical-align: middle;\n background: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e\") no-repeat right 0.75rem center/8px 10px;\n background-color: #fff;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n}\n\n.custom-select:focus {\n border-color: #80bdff;\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-select:focus::-ms-value {\n color: #495057;\n background-color: #fff;\n}\n\n.custom-select[multiple], .custom-select[size]:not([size=\"1\"]) {\n height: auto;\n padding-right: 0.75rem;\n background-image: none;\n}\n\n.custom-select:disabled {\n color: #6c757d;\n background-color: #e9ecef;\n}\n\n.custom-select::-ms-expand {\n display: none;\n}\n\n.custom-select-sm {\n height: calc(1.5em + 0.5rem + 2px);\n padding-top: 0.25rem;\n padding-bottom: 0.25rem;\n padding-left: 0.5rem;\n font-size: 0.875rem;\n}\n\n.custom-select-lg {\n height: calc(1.5em + 1rem + 2px);\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n padding-left: 1rem;\n font-size: 1.25rem;\n}\n\n.custom-file {\n position: relative;\n display: inline-block;\n width: 100%;\n height: calc(1.5em + 0.75rem + 2px);\n margin-bottom: 0;\n}\n\n.custom-file-input {\n position: relative;\n z-index: 2;\n width: 100%;\n height: calc(1.5em + 0.75rem + 2px);\n margin: 0;\n opacity: 0;\n}\n\n.custom-file-input:focus ~ .custom-file-label {\n border-color: #80bdff;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-file-input:disabled ~ .custom-file-label {\n background-color: #e9ecef;\n}\n\n.custom-file-input:lang(en) ~ .custom-file-label::after {\n content: \"Browse\";\n}\n\n.custom-file-input ~ .custom-file-label[data-browse]::after {\n content: attr(data-browse);\n}\n\n.custom-file-label {\n position: absolute;\n top: 0;\n right: 0;\n left: 0;\n z-index: 1;\n height: calc(1.5em + 0.75rem + 2px);\n padding: 0.375rem 0.75rem;\n font-weight: 400;\n line-height: 1.5;\n color: #495057;\n background-color: #fff;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n}\n\n.custom-file-label::after {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n z-index: 3;\n display: block;\n height: calc(1.5em + 0.75rem);\n padding: 0.375rem 0.75rem;\n line-height: 1.5;\n color: #495057;\n content: \"Browse\";\n background-color: #e9ecef;\n border-left: inherit;\n border-radius: 0 0.25rem 0.25rem 0;\n}\n\n.custom-range {\n width: 100%;\n height: calc(1rem + 0.4rem);\n padding: 0;\n background-color: transparent;\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n}\n\n.custom-range:focus {\n outline: none;\n}\n\n.custom-range:focus::-webkit-slider-thumb {\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-range:focus::-moz-range-thumb {\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-range:focus::-ms-thumb {\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-range::-moz-focus-outer {\n border: 0;\n}\n\n.custom-range::-webkit-slider-thumb {\n width: 1rem;\n height: 1rem;\n margin-top: -0.25rem;\n background-color: #007bff;\n border: 0;\n border-radius: 1rem;\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n -webkit-appearance: none;\n appearance: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .custom-range::-webkit-slider-thumb {\n transition: none;\n }\n}\n\n.custom-range::-webkit-slider-thumb:active {\n background-color: #b3d7ff;\n}\n\n.custom-range::-webkit-slider-runnable-track {\n width: 100%;\n height: 0.5rem;\n color: transparent;\n cursor: pointer;\n background-color: #dee2e6;\n border-color: transparent;\n border-radius: 1rem;\n}\n\n.custom-range::-moz-range-thumb {\n width: 1rem;\n height: 1rem;\n background-color: #007bff;\n border: 0;\n border-radius: 1rem;\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n -moz-appearance: none;\n appearance: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .custom-range::-moz-range-thumb {\n transition: none;\n }\n}\n\n.custom-range::-moz-range-thumb:active {\n background-color: #b3d7ff;\n}\n\n.custom-range::-moz-range-track {\n width: 100%;\n height: 0.5rem;\n color: transparent;\n cursor: pointer;\n background-color: #dee2e6;\n border-color: transparent;\n border-radius: 1rem;\n}\n\n.custom-range::-ms-thumb {\n width: 1rem;\n height: 1rem;\n margin-top: 0;\n margin-right: 0.2rem;\n margin-left: 0.2rem;\n background-color: #007bff;\n border: 0;\n border-radius: 1rem;\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n appearance: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .custom-range::-ms-thumb {\n transition: none;\n }\n}\n\n.custom-range::-ms-thumb:active {\n background-color: #b3d7ff;\n}\n\n.custom-range::-ms-track {\n width: 100%;\n height: 0.5rem;\n color: transparent;\n cursor: pointer;\n background-color: transparent;\n border-color: transparent;\n border-width: 0.5rem;\n}\n\n.custom-range::-ms-fill-lower {\n background-color: #dee2e6;\n border-radius: 1rem;\n}\n\n.custom-range::-ms-fill-upper {\n margin-right: 15px;\n background-color: #dee2e6;\n border-radius: 1rem;\n}\n\n.custom-range:disabled::-webkit-slider-thumb {\n background-color: #adb5bd;\n}\n\n.custom-range:disabled::-webkit-slider-runnable-track {\n cursor: default;\n}\n\n.custom-range:disabled::-moz-range-thumb {\n background-color: #adb5bd;\n}\n\n.custom-range:disabled::-moz-range-track {\n cursor: default;\n}\n\n.custom-range:disabled::-ms-thumb {\n background-color: #adb5bd;\n}\n\n.custom-control-label::before,\n.custom-file-label,\n.custom-select {\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .custom-control-label::before,\n .custom-file-label,\n .custom-select {\n transition: none;\n }\n}\n\n.nav {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-wrap: wrap;\n flex-wrap: wrap;\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n\n.nav-link {\n display: block;\n padding: 0.5rem 1rem;\n}\n\n.nav-link:hover, .nav-link:focus {\n text-decoration: none;\n}\n\n.nav-link.disabled {\n color: #6c757d;\n pointer-events: none;\n cursor: default;\n}\n\n.nav-tabs {\n border-bottom: 1px solid #dee2e6;\n}\n\n.nav-tabs .nav-item {\n margin-bottom: -1px;\n}\n\n.nav-tabs .nav-link {\n border: 1px solid transparent;\n border-top-left-radius: 0.25rem;\n border-top-right-radius: 0.25rem;\n}\n\n.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus {\n border-color: #e9ecef #e9ecef #dee2e6;\n}\n\n.nav-tabs .nav-link.disabled {\n color: #6c757d;\n background-color: transparent;\n border-color: transparent;\n}\n\n.nav-tabs .nav-link.active,\n.nav-tabs .nav-item.show .nav-link {\n color: #495057;\n background-color: #fff;\n border-color: #dee2e6 #dee2e6 #fff;\n}\n\n.nav-tabs .dropdown-menu {\n margin-top: -1px;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n\n.nav-pills .nav-link {\n border-radius: 0.25rem;\n}\n\n.nav-pills .nav-link.active,\n.nav-pills .show > .nav-link {\n color: #fff;\n background-color: #007bff;\n}\n\n.nav-fill .nav-item {\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n text-align: center;\n}\n\n.nav-justified .nav-item {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n -ms-flex-positive: 1;\n flex-grow: 1;\n text-align: center;\n}\n\n.tab-content > .tab-pane {\n display: none;\n}\n\n.tab-content > .active {\n display: block;\n}\n\n.navbar {\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-wrap: wrap;\n flex-wrap: wrap;\n -ms-flex-align: center;\n align-items: center;\n -ms-flex-pack: justify;\n justify-content: space-between;\n padding: 0.5rem 1rem;\n}\n\n.navbar > .container,\n.navbar > .container-fluid {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-wrap: wrap;\n flex-wrap: wrap;\n -ms-flex-align: center;\n align-items: center;\n -ms-flex-pack: justify;\n justify-content: space-between;\n}\n\n.navbar-brand {\n display: inline-block;\n padding-top: 0.3125rem;\n padding-bottom: 0.3125rem;\n margin-right: 1rem;\n font-size: 1.25rem;\n line-height: inherit;\n white-space: nowrap;\n}\n\n.navbar-brand:hover, .navbar-brand:focus {\n text-decoration: none;\n}\n\n.navbar-nav {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n\n.navbar-nav .nav-link {\n padding-right: 0;\n padding-left: 0;\n}\n\n.navbar-nav .dropdown-menu {\n position: static;\n float: none;\n}\n\n.navbar-text {\n display: inline-block;\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n}\n\n.navbar-collapse {\n -ms-flex-preferred-size: 100%;\n flex-basis: 100%;\n -ms-flex-positive: 1;\n flex-grow: 1;\n -ms-flex-align: center;\n align-items: center;\n}\n\n.navbar-toggler {\n padding: 0.25rem 0.75rem;\n font-size: 1.25rem;\n line-height: 1;\n background-color: transparent;\n border: 1px solid transparent;\n border-radius: 0.25rem;\n}\n\n.navbar-toggler:hover, .navbar-toggler:focus {\n text-decoration: none;\n}\n\n.navbar-toggler-icon {\n display: inline-block;\n width: 1.5em;\n height: 1.5em;\n vertical-align: middle;\n content: \"\";\n background: no-repeat center center;\n background-size: 100% 100%;\n}\n\n@media (max-width: 575.98px) {\n .navbar-expand-sm > .container,\n .navbar-expand-sm > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n }\n}\n\n@media (min-width: 576px) {\n .navbar-expand-sm {\n -ms-flex-flow: row nowrap;\n flex-flow: row nowrap;\n -ms-flex-pack: start;\n justify-content: flex-start;\n }\n .navbar-expand-sm .navbar-nav {\n -ms-flex-direction: row;\n flex-direction: row;\n }\n .navbar-expand-sm .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-sm .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-sm > .container,\n .navbar-expand-sm > .container-fluid {\n -ms-flex-wrap: nowrap;\n flex-wrap: nowrap;\n }\n .navbar-expand-sm .navbar-collapse {\n display: -ms-flexbox !important;\n display: flex !important;\n -ms-flex-preferred-size: auto;\n flex-basis: auto;\n }\n .navbar-expand-sm .navbar-toggler {\n display: none;\n }\n}\n\n@media (max-width: 767.98px) {\n .navbar-expand-md > .container,\n .navbar-expand-md > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n }\n}\n\n@media (min-width: 768px) {\n .navbar-expand-md {\n -ms-flex-flow: row nowrap;\n flex-flow: row nowrap;\n -ms-flex-pack: start;\n justify-content: flex-start;\n }\n .navbar-expand-md .navbar-nav {\n -ms-flex-direction: row;\n flex-direction: row;\n }\n .navbar-expand-md .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-md .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-md > .container,\n .navbar-expand-md > .container-fluid {\n -ms-flex-wrap: nowrap;\n flex-wrap: nowrap;\n }\n .navbar-expand-md .navbar-collapse {\n display: -ms-flexbox !important;\n display: flex !important;\n -ms-flex-preferred-size: auto;\n flex-basis: auto;\n }\n .navbar-expand-md .navbar-toggler {\n display: none;\n }\n}\n\n@media (max-width: 991.98px) {\n .navbar-expand-lg > .container,\n .navbar-expand-lg > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n }\n}\n\n@media (min-width: 992px) {\n .navbar-expand-lg {\n -ms-flex-flow: row nowrap;\n flex-flow: row nowrap;\n -ms-flex-pack: start;\n justify-content: flex-start;\n }\n .navbar-expand-lg .navbar-nav {\n -ms-flex-direction: row;\n flex-direction: row;\n }\n .navbar-expand-lg .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-lg .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-lg > .container,\n .navbar-expand-lg > .container-fluid {\n -ms-flex-wrap: nowrap;\n flex-wrap: nowrap;\n }\n .navbar-expand-lg .navbar-collapse {\n display: -ms-flexbox !important;\n display: flex !important;\n -ms-flex-preferred-size: auto;\n flex-basis: auto;\n }\n .navbar-expand-lg .navbar-toggler {\n display: none;\n }\n}\n\n@media (max-width: 1199.98px) {\n .navbar-expand-xl > .container,\n .navbar-expand-xl > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n }\n}\n\n@media (min-width: 1200px) {\n .navbar-expand-xl {\n -ms-flex-flow: row nowrap;\n flex-flow: row nowrap;\n -ms-flex-pack: start;\n justify-content: flex-start;\n }\n .navbar-expand-xl .navbar-nav {\n -ms-flex-direction: row;\n flex-direction: row;\n }\n .navbar-expand-xl .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-xl .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-xl > .container,\n .navbar-expand-xl > .container-fluid {\n -ms-flex-wrap: nowrap;\n flex-wrap: nowrap;\n }\n .navbar-expand-xl .navbar-collapse {\n display: -ms-flexbox !important;\n display: flex !important;\n -ms-flex-preferred-size: auto;\n flex-basis: auto;\n }\n .navbar-expand-xl .navbar-toggler {\n display: none;\n }\n}\n\n.navbar-expand {\n -ms-flex-flow: row nowrap;\n flex-flow: row nowrap;\n -ms-flex-pack: start;\n justify-content: flex-start;\n}\n\n.navbar-expand > .container,\n.navbar-expand > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n}\n\n.navbar-expand .navbar-nav {\n -ms-flex-direction: row;\n flex-direction: row;\n}\n\n.navbar-expand .navbar-nav .dropdown-menu {\n position: absolute;\n}\n\n.navbar-expand .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n}\n\n.navbar-expand > .container,\n.navbar-expand > .container-fluid {\n -ms-flex-wrap: nowrap;\n flex-wrap: nowrap;\n}\n\n.navbar-expand .navbar-collapse {\n display: -ms-flexbox !important;\n display: flex !important;\n -ms-flex-preferred-size: auto;\n flex-basis: auto;\n}\n\n.navbar-expand .navbar-toggler {\n display: none;\n}\n\n.navbar-light .navbar-brand {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-light .navbar-brand:hover, .navbar-light .navbar-brand:focus {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-light .navbar-nav .nav-link {\n color: rgba(0, 0, 0, 0.5);\n}\n\n.navbar-light .navbar-nav .nav-link:hover, .navbar-light .navbar-nav .nav-link:focus {\n color: rgba(0, 0, 0, 0.7);\n}\n\n.navbar-light .navbar-nav .nav-link.disabled {\n color: rgba(0, 0, 0, 0.3);\n}\n\n.navbar-light .navbar-nav .show > .nav-link,\n.navbar-light .navbar-nav .active > .nav-link,\n.navbar-light .navbar-nav .nav-link.show,\n.navbar-light .navbar-nav .nav-link.active {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-light .navbar-toggler {\n color: rgba(0, 0, 0, 0.5);\n border-color: rgba(0, 0, 0, 0.1);\n}\n\n.navbar-light .navbar-toggler-icon {\n background-image: url(\"data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(0, 0, 0, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e\");\n}\n\n.navbar-light .navbar-text {\n color: rgba(0, 0, 0, 0.5);\n}\n\n.navbar-light .navbar-text a {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-light .navbar-text a:hover, .navbar-light .navbar-text a:focus {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-dark .navbar-brand {\n color: #fff;\n}\n\n.navbar-dark .navbar-brand:hover, .navbar-dark .navbar-brand:focus {\n color: #fff;\n}\n\n.navbar-dark .navbar-nav .nav-link {\n color: rgba(255, 255, 255, 0.5);\n}\n\n.navbar-dark .navbar-nav .nav-link:hover, .navbar-dark .navbar-nav .nav-link:focus {\n color: rgba(255, 255, 255, 0.75);\n}\n\n.navbar-dark .navbar-nav .nav-link.disabled {\n color: rgba(255, 255, 255, 0.25);\n}\n\n.navbar-dark .navbar-nav .show > .nav-link,\n.navbar-dark .navbar-nav .active > .nav-link,\n.navbar-dark .navbar-nav .nav-link.show,\n.navbar-dark .navbar-nav .nav-link.active {\n color: #fff;\n}\n\n.navbar-dark .navbar-toggler {\n color: rgba(255, 255, 255, 0.5);\n border-color: rgba(255, 255, 255, 0.1);\n}\n\n.navbar-dark .navbar-toggler-icon {\n background-image: url(\"data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e\");\n}\n\n.navbar-dark .navbar-text {\n color: rgba(255, 255, 255, 0.5);\n}\n\n.navbar-dark .navbar-text a {\n color: #fff;\n}\n\n.navbar-dark .navbar-text a:hover, .navbar-dark .navbar-text a:focus {\n color: #fff;\n}\n\n.card {\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n min-width: 0;\n word-wrap: break-word;\n background-color: #fff;\n background-clip: border-box;\n border: 1px solid rgba(0, 0, 0, 0.125);\n border-radius: 0.25rem;\n}\n\n.card > hr {\n margin-right: 0;\n margin-left: 0;\n}\n\n.card > .list-group:first-child .list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-top-right-radius: 0.25rem;\n}\n\n.card > .list-group:last-child .list-group-item:last-child {\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n}\n\n.card-body {\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n padding: 1.25rem;\n}\n\n.card-title {\n margin-bottom: 0.75rem;\n}\n\n.card-subtitle {\n margin-top: -0.375rem;\n margin-bottom: 0;\n}\n\n.card-text:last-child {\n margin-bottom: 0;\n}\n\n.card-link:hover {\n text-decoration: none;\n}\n\n.card-link + .card-link {\n margin-left: 1.25rem;\n}\n\n.card-header {\n padding: 0.75rem 1.25rem;\n margin-bottom: 0;\n background-color: rgba(0, 0, 0, 0.03);\n border-bottom: 1px solid rgba(0, 0, 0, 0.125);\n}\n\n.card-header:first-child {\n border-radius: calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0;\n}\n\n.card-header + .list-group .list-group-item:first-child {\n border-top: 0;\n}\n\n.card-footer {\n padding: 0.75rem 1.25rem;\n background-color: rgba(0, 0, 0, 0.03);\n border-top: 1px solid rgba(0, 0, 0, 0.125);\n}\n\n.card-footer:last-child {\n border-radius: 0 0 calc(0.25rem - 1px) calc(0.25rem - 1px);\n}\n\n.card-header-tabs {\n margin-right: -0.625rem;\n margin-bottom: -0.75rem;\n margin-left: -0.625rem;\n border-bottom: 0;\n}\n\n.card-header-pills {\n margin-right: -0.625rem;\n margin-left: -0.625rem;\n}\n\n.card-img-overlay {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n padding: 1.25rem;\n}\n\n.card-img {\n width: 100%;\n border-radius: calc(0.25rem - 1px);\n}\n\n.card-img-top {\n width: 100%;\n border-top-left-radius: calc(0.25rem - 1px);\n border-top-right-radius: calc(0.25rem - 1px);\n}\n\n.card-img-bottom {\n width: 100%;\n border-bottom-right-radius: calc(0.25rem - 1px);\n border-bottom-left-radius: calc(0.25rem - 1px);\n}\n\n.card-deck {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n\n.card-deck .card {\n margin-bottom: 15px;\n}\n\n@media (min-width: 576px) {\n .card-deck {\n -ms-flex-flow: row wrap;\n flex-flow: row wrap;\n margin-right: -15px;\n margin-left: -15px;\n }\n .card-deck .card {\n display: -ms-flexbox;\n display: flex;\n -ms-flex: 1 0 0%;\n flex: 1 0 0%;\n -ms-flex-direction: column;\n flex-direction: column;\n margin-right: 15px;\n margin-bottom: 0;\n margin-left: 15px;\n }\n}\n\n.card-group {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n\n.card-group > .card {\n margin-bottom: 15px;\n}\n\n@media (min-width: 576px) {\n .card-group {\n -ms-flex-flow: row wrap;\n flex-flow: row wrap;\n }\n .card-group > .card {\n -ms-flex: 1 0 0%;\n flex: 1 0 0%;\n margin-bottom: 0;\n }\n .card-group > .card + .card {\n margin-left: 0;\n border-left: 0;\n }\n .card-group > .card:not(:last-child) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n }\n .card-group > .card:not(:last-child) .card-img-top,\n .card-group > .card:not(:last-child) .card-header {\n border-top-right-radius: 0;\n }\n .card-group > .card:not(:last-child) .card-img-bottom,\n .card-group > .card:not(:last-child) .card-footer {\n border-bottom-right-radius: 0;\n }\n .card-group > .card:not(:first-child) {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n }\n .card-group > .card:not(:first-child) .card-img-top,\n .card-group > .card:not(:first-child) .card-header {\n border-top-left-radius: 0;\n }\n .card-group > .card:not(:first-child) .card-img-bottom,\n .card-group > .card:not(:first-child) .card-footer {\n border-bottom-left-radius: 0;\n }\n}\n\n.card-columns .card {\n margin-bottom: 0.75rem;\n}\n\n@media (min-width: 576px) {\n .card-columns {\n -webkit-column-count: 3;\n -moz-column-count: 3;\n column-count: 3;\n -webkit-column-gap: 1.25rem;\n -moz-column-gap: 1.25rem;\n column-gap: 1.25rem;\n orphans: 1;\n widows: 1;\n }\n .card-columns .card {\n display: inline-block;\n width: 100%;\n }\n}\n\n.accordion > .card {\n overflow: hidden;\n}\n\n.accordion > .card:not(:first-of-type) .card-header:first-child {\n border-radius: 0;\n}\n\n.accordion > .card:not(:first-of-type):not(:last-of-type) {\n border-bottom: 0;\n border-radius: 0;\n}\n\n.accordion > .card:first-of-type {\n border-bottom: 0;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.accordion > .card:last-of-type {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n\n.accordion > .card .card-header {\n margin-bottom: -1px;\n}\n\n.breadcrumb {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-wrap: wrap;\n flex-wrap: wrap;\n padding: 0.75rem 1rem;\n margin-bottom: 1rem;\n list-style: none;\n background-color: #e9ecef;\n border-radius: 0.25rem;\n}\n\n.breadcrumb-item + .breadcrumb-item {\n padding-left: 0.5rem;\n}\n\n.breadcrumb-item + .breadcrumb-item::before {\n display: inline-block;\n padding-right: 0.5rem;\n color: #6c757d;\n content: \"/\";\n}\n\n.breadcrumb-item + .breadcrumb-item:hover::before {\n text-decoration: underline;\n}\n\n.breadcrumb-item + .breadcrumb-item:hover::before {\n text-decoration: none;\n}\n\n.breadcrumb-item.active {\n color: #6c757d;\n}\n\n.pagination {\n display: -ms-flexbox;\n display: flex;\n padding-left: 0;\n list-style: none;\n border-radius: 0.25rem;\n}\n\n.page-link {\n position: relative;\n display: block;\n padding: 0.5rem 0.75rem;\n margin-left: -1px;\n line-height: 1.25;\n color: #007bff;\n background-color: #fff;\n border: 1px solid #dee2e6;\n}\n\n.page-link:hover {\n z-index: 2;\n color: #0056b3;\n text-decoration: none;\n background-color: #e9ecef;\n border-color: #dee2e6;\n}\n\n.page-link:focus {\n z-index: 2;\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.page-item:first-child .page-link {\n margin-left: 0;\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n}\n\n.page-item:last-child .page-link {\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n}\n\n.page-item.active .page-link {\n z-index: 1;\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.page-item.disabled .page-link {\n color: #6c757d;\n pointer-events: none;\n cursor: auto;\n background-color: #fff;\n border-color: #dee2e6;\n}\n\n.pagination-lg .page-link {\n padding: 0.75rem 1.5rem;\n font-size: 1.25rem;\n line-height: 1.5;\n}\n\n.pagination-lg .page-item:first-child .page-link {\n border-top-left-radius: 0.3rem;\n border-bottom-left-radius: 0.3rem;\n}\n\n.pagination-lg .page-item:last-child .page-link {\n border-top-right-radius: 0.3rem;\n border-bottom-right-radius: 0.3rem;\n}\n\n.pagination-sm .page-link {\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n line-height: 1.5;\n}\n\n.pagination-sm .page-item:first-child .page-link {\n border-top-left-radius: 0.2rem;\n border-bottom-left-radius: 0.2rem;\n}\n\n.pagination-sm .page-item:last-child .page-link {\n border-top-right-radius: 0.2rem;\n border-bottom-right-radius: 0.2rem;\n}\n\n.badge {\n display: inline-block;\n padding: 0.25em 0.4em;\n font-size: 75%;\n font-weight: 700;\n line-height: 1;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: 0.25rem;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .badge {\n transition: none;\n }\n}\n\na.badge:hover, a.badge:focus {\n text-decoration: none;\n}\n\n.badge:empty {\n display: none;\n}\n\n.btn .badge {\n position: relative;\n top: -1px;\n}\n\n.badge-pill {\n padding-right: 0.6em;\n padding-left: 0.6em;\n border-radius: 10rem;\n}\n\n.badge-primary {\n color: #fff;\n background-color: #007bff;\n}\n\na.badge-primary:hover, a.badge-primary:focus {\n color: #fff;\n background-color: #0062cc;\n}\n\na.badge-primary:focus, a.badge-primary.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);\n}\n\n.badge-secondary {\n color: #fff;\n background-color: #6c757d;\n}\n\na.badge-secondary:hover, a.badge-secondary:focus {\n color: #fff;\n background-color: #545b62;\n}\n\na.badge-secondary:focus, a.badge-secondary.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);\n}\n\n.badge-success {\n color: #fff;\n background-color: #28a745;\n}\n\na.badge-success:hover, a.badge-success:focus {\n color: #fff;\n background-color: #1e7e34;\n}\n\na.badge-success:focus, a.badge-success.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);\n}\n\n.badge-info {\n color: #fff;\n background-color: #17a2b8;\n}\n\na.badge-info:hover, a.badge-info:focus {\n color: #fff;\n background-color: #117a8b;\n}\n\na.badge-info:focus, a.badge-info.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);\n}\n\n.badge-warning {\n color: #212529;\n background-color: #ffc107;\n}\n\na.badge-warning:hover, a.badge-warning:focus {\n color: #212529;\n background-color: #d39e00;\n}\n\na.badge-warning:focus, a.badge-warning.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);\n}\n\n.badge-danger {\n color: #fff;\n background-color: #dc3545;\n}\n\na.badge-danger:hover, a.badge-danger:focus {\n color: #fff;\n background-color: #bd2130;\n}\n\na.badge-danger:focus, a.badge-danger.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);\n}\n\n.badge-light {\n color: #212529;\n background-color: #f8f9fa;\n}\n\na.badge-light:hover, a.badge-light:focus {\n color: #212529;\n background-color: #dae0e5;\n}\n\na.badge-light:focus, a.badge-light.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);\n}\n\n.badge-dark {\n color: #fff;\n background-color: #343a40;\n}\n\na.badge-dark:hover, a.badge-dark:focus {\n color: #fff;\n background-color: #1d2124;\n}\n\na.badge-dark:focus, a.badge-dark.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);\n}\n\n.jumbotron {\n padding: 2rem 1rem;\n margin-bottom: 2rem;\n background-color: #e9ecef;\n border-radius: 0.3rem;\n}\n\n@media (min-width: 576px) {\n .jumbotron {\n padding: 4rem 2rem;\n }\n}\n\n.jumbotron-fluid {\n padding-right: 0;\n padding-left: 0;\n border-radius: 0;\n}\n\n.alert {\n position: relative;\n padding: 0.75rem 1.25rem;\n margin-bottom: 1rem;\n border: 1px solid transparent;\n border-radius: 0.25rem;\n}\n\n.alert-heading {\n color: inherit;\n}\n\n.alert-link {\n font-weight: 700;\n}\n\n.alert-dismissible {\n padding-right: 4rem;\n}\n\n.alert-dismissible .close {\n position: absolute;\n top: 0;\n right: 0;\n padding: 0.75rem 1.25rem;\n color: inherit;\n}\n\n.alert-primary {\n color: #004085;\n background-color: #cce5ff;\n border-color: #b8daff;\n}\n\n.alert-primary hr {\n border-top-color: #9fcdff;\n}\n\n.alert-primary .alert-link {\n color: #002752;\n}\n\n.alert-secondary {\n color: #383d41;\n background-color: #e2e3e5;\n border-color: #d6d8db;\n}\n\n.alert-secondary hr {\n border-top-color: #c8cbcf;\n}\n\n.alert-secondary .alert-link {\n color: #202326;\n}\n\n.alert-success {\n color: #155724;\n background-color: #d4edda;\n border-color: #c3e6cb;\n}\n\n.alert-success hr {\n border-top-color: #b1dfbb;\n}\n\n.alert-success .alert-link {\n color: #0b2e13;\n}\n\n.alert-info {\n color: #0c5460;\n background-color: #d1ecf1;\n border-color: #bee5eb;\n}\n\n.alert-info hr {\n border-top-color: #abdde5;\n}\n\n.alert-info .alert-link {\n color: #062c33;\n}\n\n.alert-warning {\n color: #856404;\n background-color: #fff3cd;\n border-color: #ffeeba;\n}\n\n.alert-warning hr {\n border-top-color: #ffe8a1;\n}\n\n.alert-warning .alert-link {\n color: #533f03;\n}\n\n.alert-danger {\n color: #721c24;\n background-color: #f8d7da;\n border-color: #f5c6cb;\n}\n\n.alert-danger hr {\n border-top-color: #f1b0b7;\n}\n\n.alert-danger .alert-link {\n color: #491217;\n}\n\n.alert-light {\n color: #818182;\n background-color: #fefefe;\n border-color: #fdfdfe;\n}\n\n.alert-light hr {\n border-top-color: #ececf6;\n}\n\n.alert-light .alert-link {\n color: #686868;\n}\n\n.alert-dark {\n color: #1b1e21;\n background-color: #d6d8d9;\n border-color: #c6c8ca;\n}\n\n.alert-dark hr {\n border-top-color: #b9bbbe;\n}\n\n.alert-dark .alert-link {\n color: #040505;\n}\n\n@-webkit-keyframes progress-bar-stripes {\n from {\n background-position: 1rem 0;\n }\n to {\n background-position: 0 0;\n }\n}\n\n@keyframes progress-bar-stripes {\n from {\n background-position: 1rem 0;\n }\n to {\n background-position: 0 0;\n }\n}\n\n.progress {\n display: -ms-flexbox;\n display: flex;\n height: 1rem;\n overflow: hidden;\n font-size: 0.75rem;\n background-color: #e9ecef;\n border-radius: 0.25rem;\n}\n\n.progress-bar {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n -ms-flex-pack: center;\n justify-content: center;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n background-color: #007bff;\n transition: width 0.6s ease;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .progress-bar {\n transition: none;\n }\n}\n\n.progress-bar-striped {\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-size: 1rem 1rem;\n}\n\n.progress-bar-animated {\n -webkit-animation: progress-bar-stripes 1s linear infinite;\n animation: progress-bar-stripes 1s linear infinite;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .progress-bar-animated {\n -webkit-animation: none;\n animation: none;\n }\n}\n\n.media {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-align: start;\n align-items: flex-start;\n}\n\n.media-body {\n -ms-flex: 1;\n flex: 1;\n}\n\n.list-group {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n padding-left: 0;\n margin-bottom: 0;\n}\n\n.list-group-item-action {\n width: 100%;\n color: #495057;\n text-align: inherit;\n}\n\n.list-group-item-action:hover, .list-group-item-action:focus {\n z-index: 1;\n color: #495057;\n text-decoration: none;\n background-color: #f8f9fa;\n}\n\n.list-group-item-action:active {\n color: #212529;\n background-color: #e9ecef;\n}\n\n.list-group-item {\n position: relative;\n display: block;\n padding: 0.75rem 1.25rem;\n margin-bottom: -1px;\n background-color: #fff;\n border: 1px solid rgba(0, 0, 0, 0.125);\n}\n\n.list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-top-right-radius: 0.25rem;\n}\n\n.list-group-item:last-child {\n margin-bottom: 0;\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n}\n\n.list-group-item.disabled, .list-group-item:disabled {\n color: #6c757d;\n pointer-events: none;\n background-color: #fff;\n}\n\n.list-group-item.active {\n z-index: 2;\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.list-group-horizontal {\n -ms-flex-direction: row;\n flex-direction: row;\n}\n\n.list-group-horizontal .list-group-item {\n margin-right: -1px;\n margin-bottom: 0;\n}\n\n.list-group-horizontal .list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n}\n\n.list-group-horizontal .list-group-item:last-child {\n margin-right: 0;\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n}\n\n@media (min-width: 576px) {\n .list-group-horizontal-sm {\n -ms-flex-direction: row;\n flex-direction: row;\n }\n .list-group-horizontal-sm .list-group-item {\n margin-right: -1px;\n margin-bottom: 0;\n }\n .list-group-horizontal-sm .list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n }\n .list-group-horizontal-sm .list-group-item:last-child {\n margin-right: 0;\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n }\n}\n\n@media (min-width: 768px) {\n .list-group-horizontal-md {\n -ms-flex-direction: row;\n flex-direction: row;\n }\n .list-group-horizontal-md .list-group-item {\n margin-right: -1px;\n margin-bottom: 0;\n }\n .list-group-horizontal-md .list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n }\n .list-group-horizontal-md .list-group-item:last-child {\n margin-right: 0;\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n }\n}\n\n@media (min-width: 992px) {\n .list-group-horizontal-lg {\n -ms-flex-direction: row;\n flex-direction: row;\n }\n .list-group-horizontal-lg .list-group-item {\n margin-right: -1px;\n margin-bottom: 0;\n }\n .list-group-horizontal-lg .list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n }\n .list-group-horizontal-lg .list-group-item:last-child {\n margin-right: 0;\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n }\n}\n\n@media (min-width: 1200px) {\n .list-group-horizontal-xl {\n -ms-flex-direction: row;\n flex-direction: row;\n }\n .list-group-horizontal-xl .list-group-item {\n margin-right: -1px;\n margin-bottom: 0;\n }\n .list-group-horizontal-xl .list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n }\n .list-group-horizontal-xl .list-group-item:last-child {\n margin-right: 0;\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n }\n}\n\n.list-group-flush .list-group-item {\n border-right: 0;\n border-left: 0;\n border-radius: 0;\n}\n\n.list-group-flush .list-group-item:last-child {\n margin-bottom: -1px;\n}\n\n.list-group-flush:first-child .list-group-item:first-child {\n border-top: 0;\n}\n\n.list-group-flush:last-child .list-group-item:last-child {\n margin-bottom: 0;\n border-bottom: 0;\n}\n\n.list-group-item-primary {\n color: #004085;\n background-color: #b8daff;\n}\n\n.list-group-item-primary.list-group-item-action:hover, .list-group-item-primary.list-group-item-action:focus {\n color: #004085;\n background-color: #9fcdff;\n}\n\n.list-group-item-primary.list-group-item-action.active {\n color: #fff;\n background-color: #004085;\n border-color: #004085;\n}\n\n.list-group-item-secondary {\n color: #383d41;\n background-color: #d6d8db;\n}\n\n.list-group-item-secondary.list-group-item-action:hover, .list-group-item-secondary.list-group-item-action:focus {\n color: #383d41;\n background-color: #c8cbcf;\n}\n\n.list-group-item-secondary.list-group-item-action.active {\n color: #fff;\n background-color: #383d41;\n border-color: #383d41;\n}\n\n.list-group-item-success {\n color: #155724;\n background-color: #c3e6cb;\n}\n\n.list-group-item-success.list-group-item-action:hover, .list-group-item-success.list-group-item-action:focus {\n color: #155724;\n background-color: #b1dfbb;\n}\n\n.list-group-item-success.list-group-item-action.active {\n color: #fff;\n background-color: #155724;\n border-color: #155724;\n}\n\n.list-group-item-info {\n color: #0c5460;\n background-color: #bee5eb;\n}\n\n.list-group-item-info.list-group-item-action:hover, .list-group-item-info.list-group-item-action:focus {\n color: #0c5460;\n background-color: #abdde5;\n}\n\n.list-group-item-info.list-group-item-action.active {\n color: #fff;\n background-color: #0c5460;\n border-color: #0c5460;\n}\n\n.list-group-item-warning {\n color: #856404;\n background-color: #ffeeba;\n}\n\n.list-group-item-warning.list-group-item-action:hover, .list-group-item-warning.list-group-item-action:focus {\n color: #856404;\n background-color: #ffe8a1;\n}\n\n.list-group-item-warning.list-group-item-action.active {\n color: #fff;\n background-color: #856404;\n border-color: #856404;\n}\n\n.list-group-item-danger {\n color: #721c24;\n background-color: #f5c6cb;\n}\n\n.list-group-item-danger.list-group-item-action:hover, .list-group-item-danger.list-group-item-action:focus {\n color: #721c24;\n background-color: #f1b0b7;\n}\n\n.list-group-item-danger.list-group-item-action.active {\n color: #fff;\n background-color: #721c24;\n border-color: #721c24;\n}\n\n.list-group-item-light {\n color: #818182;\n background-color: #fdfdfe;\n}\n\n.list-group-item-light.list-group-item-action:hover, .list-group-item-light.list-group-item-action:focus {\n color: #818182;\n background-color: #ececf6;\n}\n\n.list-group-item-light.list-group-item-action.active {\n color: #fff;\n background-color: #818182;\n border-color: #818182;\n}\n\n.list-group-item-dark {\n color: #1b1e21;\n background-color: #c6c8ca;\n}\n\n.list-group-item-dark.list-group-item-action:hover, .list-group-item-dark.list-group-item-action:focus {\n color: #1b1e21;\n background-color: #b9bbbe;\n}\n\n.list-group-item-dark.list-group-item-action.active {\n color: #fff;\n background-color: #1b1e21;\n border-color: #1b1e21;\n}\n\n.close {\n float: right;\n font-size: 1.5rem;\n font-weight: 700;\n line-height: 1;\n color: #000;\n text-shadow: 0 1px 0 #fff;\n opacity: .5;\n}\n\n.close:hover {\n color: #000;\n text-decoration: none;\n}\n\n.close:not(:disabled):not(.disabled):hover, .close:not(:disabled):not(.disabled):focus {\n opacity: .75;\n}\n\nbutton.close {\n padding: 0;\n background-color: transparent;\n border: 0;\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n}\n\na.close.disabled {\n pointer-events: none;\n}\n\n.toast {\n max-width: 350px;\n overflow: hidden;\n font-size: 0.875rem;\n background-color: rgba(255, 255, 255, 0.85);\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.1);\n box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);\n -webkit-backdrop-filter: blur(10px);\n backdrop-filter: blur(10px);\n opacity: 0;\n border-radius: 0.25rem;\n}\n\n.toast:not(:last-child) {\n margin-bottom: 0.75rem;\n}\n\n.toast.showing {\n opacity: 1;\n}\n\n.toast.show {\n display: block;\n opacity: 1;\n}\n\n.toast.hide {\n display: none;\n}\n\n.toast-header {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-align: center;\n align-items: center;\n padding: 0.25rem 0.75rem;\n color: #6c757d;\n background-color: rgba(255, 255, 255, 0.85);\n background-clip: padding-box;\n border-bottom: 1px solid rgba(0, 0, 0, 0.05);\n}\n\n.toast-body {\n padding: 0.75rem;\n}\n\n.modal-open {\n overflow: hidden;\n}\n\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n\n.modal {\n position: fixed;\n top: 0;\n left: 0;\n z-index: 1050;\n display: none;\n width: 100%;\n height: 100%;\n overflow: hidden;\n outline: 0;\n}\n\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 0.5rem;\n pointer-events: none;\n}\n\n.modal.fade .modal-dialog {\n transition: -webkit-transform 0.3s ease-out;\n transition: transform 0.3s ease-out;\n transition: transform 0.3s ease-out, -webkit-transform 0.3s ease-out;\n -webkit-transform: translate(0, -50px);\n transform: translate(0, -50px);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .modal.fade .modal-dialog {\n transition: none;\n }\n}\n\n.modal.show .modal-dialog {\n -webkit-transform: none;\n transform: none;\n}\n\n.modal-dialog-scrollable {\n display: -ms-flexbox;\n display: flex;\n max-height: calc(100% - 1rem);\n}\n\n.modal-dialog-scrollable .modal-content {\n max-height: calc(100vh - 1rem);\n overflow: hidden;\n}\n\n.modal-dialog-scrollable .modal-header,\n.modal-dialog-scrollable .modal-footer {\n -ms-flex-negative: 0;\n flex-shrink: 0;\n}\n\n.modal-dialog-scrollable .modal-body {\n overflow-y: auto;\n}\n\n.modal-dialog-centered {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-align: center;\n align-items: center;\n min-height: calc(100% - 1rem);\n}\n\n.modal-dialog-centered::before {\n display: block;\n height: calc(100vh - 1rem);\n content: \"\";\n}\n\n.modal-dialog-centered.modal-dialog-scrollable {\n -ms-flex-direction: column;\n flex-direction: column;\n -ms-flex-pack: center;\n justify-content: center;\n height: 100%;\n}\n\n.modal-dialog-centered.modal-dialog-scrollable .modal-content {\n max-height: none;\n}\n\n.modal-dialog-centered.modal-dialog-scrollable::before {\n content: none;\n}\n\n.modal-content {\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n width: 100%;\n pointer-events: auto;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 0.3rem;\n outline: 0;\n}\n\n.modal-backdrop {\n position: fixed;\n top: 0;\n left: 0;\n z-index: 1040;\n width: 100vw;\n height: 100vh;\n background-color: #000;\n}\n\n.modal-backdrop.fade {\n opacity: 0;\n}\n\n.modal-backdrop.show {\n opacity: 0.5;\n}\n\n.modal-header {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-align: start;\n align-items: flex-start;\n -ms-flex-pack: justify;\n justify-content: space-between;\n padding: 1rem 1rem;\n border-bottom: 1px solid #dee2e6;\n border-top-left-radius: 0.3rem;\n border-top-right-radius: 0.3rem;\n}\n\n.modal-header .close {\n padding: 1rem 1rem;\n margin: -1rem -1rem -1rem auto;\n}\n\n.modal-title {\n margin-bottom: 0;\n line-height: 1.5;\n}\n\n.modal-body {\n position: relative;\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n padding: 1rem;\n}\n\n.modal-footer {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-align: center;\n align-items: center;\n -ms-flex-pack: end;\n justify-content: flex-end;\n padding: 1rem;\n border-top: 1px solid #dee2e6;\n border-bottom-right-radius: 0.3rem;\n border-bottom-left-radius: 0.3rem;\n}\n\n.modal-footer > :not(:first-child) {\n margin-left: .25rem;\n}\n\n.modal-footer > :not(:last-child) {\n margin-right: .25rem;\n}\n\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n\n@media (min-width: 576px) {\n .modal-dialog {\n max-width: 500px;\n margin: 1.75rem auto;\n }\n .modal-dialog-scrollable {\n max-height: calc(100% - 3.5rem);\n }\n .modal-dialog-scrollable .modal-content {\n max-height: calc(100vh - 3.5rem);\n }\n .modal-dialog-centered {\n min-height: calc(100% - 3.5rem);\n }\n .modal-dialog-centered::before {\n height: calc(100vh - 3.5rem);\n }\n .modal-sm {\n max-width: 300px;\n }\n}\n\n@media (min-width: 992px) {\n .modal-lg,\n .modal-xl {\n max-width: 800px;\n }\n}\n\n@media (min-width: 1200px) {\n .modal-xl {\n max-width: 1140px;\n }\n}\n\n.tooltip {\n position: absolute;\n z-index: 1070;\n display: block;\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n font-style: normal;\n font-weight: 400;\n line-height: 1.5;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n white-space: normal;\n line-break: auto;\n font-size: 0.875rem;\n word-wrap: break-word;\n opacity: 0;\n}\n\n.tooltip.show {\n opacity: 0.9;\n}\n\n.tooltip .arrow {\n position: absolute;\n display: block;\n width: 0.8rem;\n height: 0.4rem;\n}\n\n.tooltip .arrow::before {\n position: absolute;\n content: \"\";\n border-color: transparent;\n border-style: solid;\n}\n\n.bs-tooltip-top, .bs-tooltip-auto[x-placement^=\"top\"] {\n padding: 0.4rem 0;\n}\n\n.bs-tooltip-top .arrow, .bs-tooltip-auto[x-placement^=\"top\"] .arrow {\n bottom: 0;\n}\n\n.bs-tooltip-top .arrow::before, .bs-tooltip-auto[x-placement^=\"top\"] .arrow::before {\n top: 0;\n border-width: 0.4rem 0.4rem 0;\n border-top-color: #000;\n}\n\n.bs-tooltip-right, .bs-tooltip-auto[x-placement^=\"right\"] {\n padding: 0 0.4rem;\n}\n\n.bs-tooltip-right .arrow, .bs-tooltip-auto[x-placement^=\"right\"] .arrow {\n left: 0;\n width: 0.4rem;\n height: 0.8rem;\n}\n\n.bs-tooltip-right .arrow::before, .bs-tooltip-auto[x-placement^=\"right\"] .arrow::before {\n right: 0;\n border-width: 0.4rem 0.4rem 0.4rem 0;\n border-right-color: #000;\n}\n\n.bs-tooltip-bottom, .bs-tooltip-auto[x-placement^=\"bottom\"] {\n padding: 0.4rem 0;\n}\n\n.bs-tooltip-bottom .arrow, .bs-tooltip-auto[x-placement^=\"bottom\"] .arrow {\n top: 0;\n}\n\n.bs-tooltip-bottom .arrow::before, .bs-tooltip-auto[x-placement^=\"bottom\"] .arrow::before {\n bottom: 0;\n border-width: 0 0.4rem 0.4rem;\n border-bottom-color: #000;\n}\n\n.bs-tooltip-left, .bs-tooltip-auto[x-placement^=\"left\"] {\n padding: 0 0.4rem;\n}\n\n.bs-tooltip-left .arrow, .bs-tooltip-auto[x-placement^=\"left\"] .arrow {\n right: 0;\n width: 0.4rem;\n height: 0.8rem;\n}\n\n.bs-tooltip-left .arrow::before, .bs-tooltip-auto[x-placement^=\"left\"] .arrow::before {\n left: 0;\n border-width: 0.4rem 0 0.4rem 0.4rem;\n border-left-color: #000;\n}\n\n.tooltip-inner {\n max-width: 200px;\n padding: 0.25rem 0.5rem;\n color: #fff;\n text-align: center;\n background-color: #000;\n border-radius: 0.25rem;\n}\n\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: 1060;\n display: block;\n max-width: 276px;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n font-style: normal;\n font-weight: 400;\n line-height: 1.5;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n white-space: normal;\n line-break: auto;\n font-size: 0.875rem;\n word-wrap: break-word;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 0.3rem;\n}\n\n.popover .arrow {\n position: absolute;\n display: block;\n width: 1rem;\n height: 0.5rem;\n margin: 0 0.3rem;\n}\n\n.popover .arrow::before, .popover .arrow::after {\n position: absolute;\n display: block;\n content: \"\";\n border-color: transparent;\n border-style: solid;\n}\n\n.bs-popover-top, .bs-popover-auto[x-placement^=\"top\"] {\n margin-bottom: 0.5rem;\n}\n\n.bs-popover-top > .arrow, .bs-popover-auto[x-placement^=\"top\"] > .arrow {\n bottom: calc((0.5rem + 1px) * -1);\n}\n\n.bs-popover-top > .arrow::before, .bs-popover-auto[x-placement^=\"top\"] > .arrow::before {\n bottom: 0;\n border-width: 0.5rem 0.5rem 0;\n border-top-color: rgba(0, 0, 0, 0.25);\n}\n\n.bs-popover-top > .arrow::after, .bs-popover-auto[x-placement^=\"top\"] > .arrow::after {\n bottom: 1px;\n border-width: 0.5rem 0.5rem 0;\n border-top-color: #fff;\n}\n\n.bs-popover-right, .bs-popover-auto[x-placement^=\"right\"] {\n margin-left: 0.5rem;\n}\n\n.bs-popover-right > .arrow, .bs-popover-auto[x-placement^=\"right\"] > .arrow {\n left: calc((0.5rem + 1px) * -1);\n width: 0.5rem;\n height: 1rem;\n margin: 0.3rem 0;\n}\n\n.bs-popover-right > .arrow::before, .bs-popover-auto[x-placement^=\"right\"] > .arrow::before {\n left: 0;\n border-width: 0.5rem 0.5rem 0.5rem 0;\n border-right-color: rgba(0, 0, 0, 0.25);\n}\n\n.bs-popover-right > .arrow::after, .bs-popover-auto[x-placement^=\"right\"] > .arrow::after {\n left: 1px;\n border-width: 0.5rem 0.5rem 0.5rem 0;\n border-right-color: #fff;\n}\n\n.bs-popover-bottom, .bs-popover-auto[x-placement^=\"bottom\"] {\n margin-top: 0.5rem;\n}\n\n.bs-popover-bottom > .arrow, .bs-popover-auto[x-placement^=\"bottom\"] > .arrow {\n top: calc((0.5rem + 1px) * -1);\n}\n\n.bs-popover-bottom > .arrow::before, .bs-popover-auto[x-placement^=\"bottom\"] > .arrow::before {\n top: 0;\n border-width: 0 0.5rem 0.5rem 0.5rem;\n border-bottom-color: rgba(0, 0, 0, 0.25);\n}\n\n.bs-popover-bottom > .arrow::after, .bs-popover-auto[x-placement^=\"bottom\"] > .arrow::after {\n top: 1px;\n border-width: 0 0.5rem 0.5rem 0.5rem;\n border-bottom-color: #fff;\n}\n\n.bs-popover-bottom .popover-header::before, .bs-popover-auto[x-placement^=\"bottom\"] .popover-header::before {\n position: absolute;\n top: 0;\n left: 50%;\n display: block;\n width: 1rem;\n margin-left: -0.5rem;\n content: \"\";\n border-bottom: 1px solid #f7f7f7;\n}\n\n.bs-popover-left, .bs-popover-auto[x-placement^=\"left\"] {\n margin-right: 0.5rem;\n}\n\n.bs-popover-left > .arrow, .bs-popover-auto[x-placement^=\"left\"] > .arrow {\n right: calc((0.5rem + 1px) * -1);\n width: 0.5rem;\n height: 1rem;\n margin: 0.3rem 0;\n}\n\n.bs-popover-left > .arrow::before, .bs-popover-auto[x-placement^=\"left\"] > .arrow::before {\n right: 0;\n border-width: 0.5rem 0 0.5rem 0.5rem;\n border-left-color: rgba(0, 0, 0, 0.25);\n}\n\n.bs-popover-left > .arrow::after, .bs-popover-auto[x-placement^=\"left\"] > .arrow::after {\n right: 1px;\n border-width: 0.5rem 0 0.5rem 0.5rem;\n border-left-color: #fff;\n}\n\n.popover-header {\n padding: 0.5rem 0.75rem;\n margin-bottom: 0;\n font-size: 1rem;\n background-color: #f7f7f7;\n border-bottom: 1px solid #ebebeb;\n border-top-left-radius: calc(0.3rem - 1px);\n border-top-right-radius: calc(0.3rem - 1px);\n}\n\n.popover-header:empty {\n display: none;\n}\n\n.popover-body {\n padding: 0.5rem 0.75rem;\n color: #212529;\n}\n\n.carousel {\n position: relative;\n}\n\n.carousel.pointer-event {\n -ms-touch-action: pan-y;\n touch-action: pan-y;\n}\n\n.carousel-inner {\n position: relative;\n width: 100%;\n overflow: hidden;\n}\n\n.carousel-inner::after {\n display: block;\n clear: both;\n content: \"\";\n}\n\n.carousel-item {\n position: relative;\n display: none;\n float: left;\n width: 100%;\n margin-right: -100%;\n -webkit-backface-visibility: hidden;\n backface-visibility: hidden;\n transition: -webkit-transform 0.6s ease-in-out;\n transition: transform 0.6s ease-in-out;\n transition: transform 0.6s ease-in-out, -webkit-transform 0.6s ease-in-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .carousel-item {\n transition: none;\n }\n}\n\n.carousel-item.active,\n.carousel-item-next,\n.carousel-item-prev {\n display: block;\n}\n\n.carousel-item-next:not(.carousel-item-left),\n.active.carousel-item-right {\n -webkit-transform: translateX(100%);\n transform: translateX(100%);\n}\n\n.carousel-item-prev:not(.carousel-item-right),\n.active.carousel-item-left {\n -webkit-transform: translateX(-100%);\n transform: translateX(-100%);\n}\n\n.carousel-fade .carousel-item {\n opacity: 0;\n transition-property: opacity;\n -webkit-transform: none;\n transform: none;\n}\n\n.carousel-fade .carousel-item.active,\n.carousel-fade .carousel-item-next.carousel-item-left,\n.carousel-fade .carousel-item-prev.carousel-item-right {\n z-index: 1;\n opacity: 1;\n}\n\n.carousel-fade .active.carousel-item-left,\n.carousel-fade .active.carousel-item-right {\n z-index: 0;\n opacity: 0;\n transition: 0s 0.6s opacity;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .carousel-fade .active.carousel-item-left,\n .carousel-fade .active.carousel-item-right {\n transition: none;\n }\n}\n\n.carousel-control-prev,\n.carousel-control-next {\n position: absolute;\n top: 0;\n bottom: 0;\n z-index: 1;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-align: center;\n align-items: center;\n -ms-flex-pack: center;\n justify-content: center;\n width: 15%;\n color: #fff;\n text-align: center;\n opacity: 0.5;\n transition: opacity 0.15s ease;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .carousel-control-prev,\n .carousel-control-next {\n transition: none;\n }\n}\n\n.carousel-control-prev:hover, .carousel-control-prev:focus,\n.carousel-control-next:hover,\n.carousel-control-next:focus {\n color: #fff;\n text-decoration: none;\n outline: 0;\n opacity: 0.9;\n}\n\n.carousel-control-prev {\n left: 0;\n}\n\n.carousel-control-next {\n right: 0;\n}\n\n.carousel-control-prev-icon,\n.carousel-control-next-icon {\n display: inline-block;\n width: 20px;\n height: 20px;\n background: no-repeat 50% / 100% 100%;\n}\n\n.carousel-control-prev-icon {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3e%3c/svg%3e\");\n}\n\n.carousel-control-next-icon {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3e%3c/svg%3e\");\n}\n\n.carousel-indicators {\n position: absolute;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 15;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-pack: center;\n justify-content: center;\n padding-left: 0;\n margin-right: 15%;\n margin-left: 15%;\n list-style: none;\n}\n\n.carousel-indicators li {\n box-sizing: content-box;\n -ms-flex: 0 1 auto;\n flex: 0 1 auto;\n width: 30px;\n height: 3px;\n margin-right: 3px;\n margin-left: 3px;\n text-indent: -999px;\n cursor: pointer;\n background-color: #fff;\n background-clip: padding-box;\n border-top: 10px solid transparent;\n border-bottom: 10px solid transparent;\n opacity: .5;\n transition: opacity 0.6s ease;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .carousel-indicators li {\n transition: none;\n }\n}\n\n.carousel-indicators .active {\n opacity: 1;\n}\n\n.carousel-caption {\n position: absolute;\n right: 15%;\n bottom: 20px;\n left: 15%;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: #fff;\n text-align: center;\n}\n\n@-webkit-keyframes spinner-border {\n to {\n -webkit-transform: rotate(360deg);\n transform: rotate(360deg);\n }\n}\n\n@keyframes spinner-border {\n to {\n -webkit-transform: rotate(360deg);\n transform: rotate(360deg);\n }\n}\n\n.spinner-border {\n display: inline-block;\n width: 2rem;\n height: 2rem;\n vertical-align: text-bottom;\n border: 0.25em solid currentColor;\n border-right-color: transparent;\n border-radius: 50%;\n -webkit-animation: spinner-border .75s linear infinite;\n animation: spinner-border .75s linear infinite;\n}\n\n.spinner-border-sm {\n width: 1rem;\n height: 1rem;\n border-width: 0.2em;\n}\n\n@-webkit-keyframes spinner-grow {\n 0% {\n -webkit-transform: scale(0);\n transform: scale(0);\n }\n 50% {\n opacity: 1;\n }\n}\n\n@keyframes spinner-grow {\n 0% {\n -webkit-transform: scale(0);\n transform: scale(0);\n }\n 50% {\n opacity: 1;\n }\n}\n\n.spinner-grow {\n display: inline-block;\n width: 2rem;\n height: 2rem;\n vertical-align: text-bottom;\n background-color: currentColor;\n border-radius: 50%;\n opacity: 0;\n -webkit-animation: spinner-grow .75s linear infinite;\n animation: spinner-grow .75s linear infinite;\n}\n\n.spinner-grow-sm {\n width: 1rem;\n height: 1rem;\n}\n\n.align-baseline {\n vertical-align: baseline !important;\n}\n\n.align-top {\n vertical-align: top !important;\n}\n\n.align-middle {\n vertical-align: middle !important;\n}\n\n.align-bottom {\n vertical-align: bottom !important;\n}\n\n.align-text-bottom {\n vertical-align: text-bottom !important;\n}\n\n.align-text-top {\n vertical-align: text-top !important;\n}\n\n.bg-primary {\n background-color: #007bff !important;\n}\n\na.bg-primary:hover, a.bg-primary:focus,\nbutton.bg-primary:hover,\nbutton.bg-primary:focus {\n background-color: #0062cc !important;\n}\n\n.bg-secondary {\n background-color: #6c757d !important;\n}\n\na.bg-secondary:hover, a.bg-secondary:focus,\nbutton.bg-secondary:hover,\nbutton.bg-secondary:focus {\n background-color: #545b62 !important;\n}\n\n.bg-success {\n background-color: #28a745 !important;\n}\n\na.bg-success:hover, a.bg-success:focus,\nbutton.bg-success:hover,\nbutton.bg-success:focus {\n background-color: #1e7e34 !important;\n}\n\n.bg-info {\n background-color: #17a2b8 !important;\n}\n\na.bg-info:hover, a.bg-info:focus,\nbutton.bg-info:hover,\nbutton.bg-info:focus {\n background-color: #117a8b !important;\n}\n\n.bg-warning {\n background-color: #ffc107 !important;\n}\n\na.bg-warning:hover, a.bg-warning:focus,\nbutton.bg-warning:hover,\nbutton.bg-warning:focus {\n background-color: #d39e00 !important;\n}\n\n.bg-danger {\n background-color: #dc3545 !important;\n}\n\na.bg-danger:hover, a.bg-danger:focus,\nbutton.bg-danger:hover,\nbutton.bg-danger:focus {\n background-color: #bd2130 !important;\n}\n\n.bg-light {\n background-color: #f8f9fa !important;\n}\n\na.bg-light:hover, a.bg-light:focus,\nbutton.bg-light:hover,\nbutton.bg-light:focus {\n background-color: #dae0e5 !important;\n}\n\n.bg-dark {\n background-color: #343a40 !important;\n}\n\na.bg-dark:hover, a.bg-dark:focus,\nbutton.bg-dark:hover,\nbutton.bg-dark:focus {\n background-color: #1d2124 !important;\n}\n\n.bg-white {\n background-color: #fff !important;\n}\n\n.bg-transparent {\n background-color: transparent !important;\n}\n\n.border {\n border: 1px solid #dee2e6 !important;\n}\n\n.border-top {\n border-top: 1px solid #dee2e6 !important;\n}\n\n.border-right {\n border-right: 1px solid #dee2e6 !important;\n}\n\n.border-bottom {\n border-bottom: 1px solid #dee2e6 !important;\n}\n\n.border-left {\n border-left: 1px solid #dee2e6 !important;\n}\n\n.border-0 {\n border: 0 !important;\n}\n\n.border-top-0 {\n border-top: 0 !important;\n}\n\n.border-right-0 {\n border-right: 0 !important;\n}\n\n.border-bottom-0 {\n border-bottom: 0 !important;\n}\n\n.border-left-0 {\n border-left: 0 !important;\n}\n\n.border-primary {\n border-color: #007bff !important;\n}\n\n.border-secondary {\n border-color: #6c757d !important;\n}\n\n.border-success {\n border-color: #28a745 !important;\n}\n\n.border-info {\n border-color: #17a2b8 !important;\n}\n\n.border-warning {\n border-color: #ffc107 !important;\n}\n\n.border-danger {\n border-color: #dc3545 !important;\n}\n\n.border-light {\n border-color: #f8f9fa !important;\n}\n\n.border-dark {\n border-color: #343a40 !important;\n}\n\n.border-white {\n border-color: #fff !important;\n}\n\n.rounded-sm {\n border-radius: 0.2rem !important;\n}\n\n.rounded {\n border-radius: 0.25rem !important;\n}\n\n.rounded-top {\n border-top-left-radius: 0.25rem !important;\n border-top-right-radius: 0.25rem !important;\n}\n\n.rounded-right {\n border-top-right-radius: 0.25rem !important;\n border-bottom-right-radius: 0.25rem !important;\n}\n\n.rounded-bottom {\n border-bottom-right-radius: 0.25rem !important;\n border-bottom-left-radius: 0.25rem !important;\n}\n\n.rounded-left {\n border-top-left-radius: 0.25rem !important;\n border-bottom-left-radius: 0.25rem !important;\n}\n\n.rounded-lg {\n border-radius: 0.3rem !important;\n}\n\n.rounded-circle {\n border-radius: 50% !important;\n}\n\n.rounded-pill {\n border-radius: 50rem !important;\n}\n\n.rounded-0 {\n border-radius: 0 !important;\n}\n\n.clearfix::after {\n display: block;\n clear: both;\n content: \"\";\n}\n\n.d-none {\n display: none !important;\n}\n\n.d-inline {\n display: inline !important;\n}\n\n.d-inline-block {\n display: inline-block !important;\n}\n\n.d-block {\n display: block !important;\n}\n\n.d-table {\n display: table !important;\n}\n\n.d-table-row {\n display: table-row !important;\n}\n\n.d-table-cell {\n display: table-cell !important;\n}\n\n.d-flex {\n display: -ms-flexbox !important;\n display: flex !important;\n}\n\n.d-inline-flex {\n display: -ms-inline-flexbox !important;\n display: inline-flex !important;\n}\n\n@media (min-width: 576px) {\n .d-sm-none {\n display: none !important;\n }\n .d-sm-inline {\n display: inline !important;\n }\n .d-sm-inline-block {\n display: inline-block !important;\n }\n .d-sm-block {\n display: block !important;\n }\n .d-sm-table {\n display: table !important;\n }\n .d-sm-table-row {\n display: table-row !important;\n }\n .d-sm-table-cell {\n display: table-cell !important;\n }\n .d-sm-flex {\n display: -ms-flexbox !important;\n display: flex !important;\n }\n .d-sm-inline-flex {\n display: -ms-inline-flexbox !important;\n display: inline-flex !important;\n }\n}\n\n@media (min-width: 768px) {\n .d-md-none {\n display: none !important;\n }\n .d-md-inline {\n display: inline !important;\n }\n .d-md-inline-block {\n display: inline-block !important;\n }\n .d-md-block {\n display: block !important;\n }\n .d-md-table {\n display: table !important;\n }\n .d-md-table-row {\n display: table-row !important;\n }\n .d-md-table-cell {\n display: table-cell !important;\n }\n .d-md-flex {\n display: -ms-flexbox !important;\n display: flex !important;\n }\n .d-md-inline-flex {\n display: -ms-inline-flexbox !important;\n display: inline-flex !important;\n }\n}\n\n@media (min-width: 992px) {\n .d-lg-none {\n display: none !important;\n }\n .d-lg-inline {\n display: inline !important;\n }\n .d-lg-inline-block {\n display: inline-block !important;\n }\n .d-lg-block {\n display: block !important;\n }\n .d-lg-table {\n display: table !important;\n }\n .d-lg-table-row {\n display: table-row !important;\n }\n .d-lg-table-cell {\n display: table-cell !important;\n }\n .d-lg-flex {\n display: -ms-flexbox !important;\n display: flex !important;\n }\n .d-lg-inline-flex {\n display: -ms-inline-flexbox !important;\n display: inline-flex !important;\n }\n}\n\n@media (min-width: 1200px) {\n .d-xl-none {\n display: none !important;\n }\n .d-xl-inline {\n display: inline !important;\n }\n .d-xl-inline-block {\n display: inline-block !important;\n }\n .d-xl-block {\n display: block !important;\n }\n .d-xl-table {\n display: table !important;\n }\n .d-xl-table-row {\n display: table-row !important;\n }\n .d-xl-table-cell {\n display: table-cell !important;\n }\n .d-xl-flex {\n display: -ms-flexbox !important;\n display: flex !important;\n }\n .d-xl-inline-flex {\n display: -ms-inline-flexbox !important;\n display: inline-flex !important;\n }\n}\n\n@media print {\n .d-print-none {\n display: none !important;\n }\n .d-print-inline {\n display: inline !important;\n }\n .d-print-inline-block {\n display: inline-block !important;\n }\n .d-print-block {\n display: block !important;\n }\n .d-print-table {\n display: table !important;\n }\n .d-print-table-row {\n display: table-row !important;\n }\n .d-print-table-cell {\n display: table-cell !important;\n }\n .d-print-flex {\n display: -ms-flexbox !important;\n display: flex !important;\n }\n .d-print-inline-flex {\n display: -ms-inline-flexbox !important;\n display: inline-flex !important;\n }\n}\n\n.embed-responsive {\n position: relative;\n display: block;\n width: 100%;\n padding: 0;\n overflow: hidden;\n}\n\n.embed-responsive::before {\n display: block;\n content: \"\";\n}\n\n.embed-responsive .embed-responsive-item,\n.embed-responsive iframe,\n.embed-responsive embed,\n.embed-responsive object,\n.embed-responsive video {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n width: 100%;\n height: 100%;\n border: 0;\n}\n\n.embed-responsive-21by9::before {\n padding-top: 42.857143%;\n}\n\n.embed-responsive-16by9::before {\n padding-top: 56.25%;\n}\n\n.embed-responsive-4by3::before {\n padding-top: 75%;\n}\n\n.embed-responsive-1by1::before {\n padding-top: 100%;\n}\n\n.flex-row {\n -ms-flex-direction: row !important;\n flex-direction: row !important;\n}\n\n.flex-column {\n -ms-flex-direction: column !important;\n flex-direction: column !important;\n}\n\n.flex-row-reverse {\n -ms-flex-direction: row-reverse !important;\n flex-direction: row-reverse !important;\n}\n\n.flex-column-reverse {\n -ms-flex-direction: column-reverse !important;\n flex-direction: column-reverse !important;\n}\n\n.flex-wrap {\n -ms-flex-wrap: wrap !important;\n flex-wrap: wrap !important;\n}\n\n.flex-nowrap {\n -ms-flex-wrap: nowrap !important;\n flex-wrap: nowrap !important;\n}\n\n.flex-wrap-reverse {\n -ms-flex-wrap: wrap-reverse !important;\n flex-wrap: wrap-reverse !important;\n}\n\n.flex-fill {\n -ms-flex: 1 1 auto !important;\n flex: 1 1 auto !important;\n}\n\n.flex-grow-0 {\n -ms-flex-positive: 0 !important;\n flex-grow: 0 !important;\n}\n\n.flex-grow-1 {\n -ms-flex-positive: 1 !important;\n flex-grow: 1 !important;\n}\n\n.flex-shrink-0 {\n -ms-flex-negative: 0 !important;\n flex-shrink: 0 !important;\n}\n\n.flex-shrink-1 {\n -ms-flex-negative: 1 !important;\n flex-shrink: 1 !important;\n}\n\n.justify-content-start {\n -ms-flex-pack: start !important;\n justify-content: flex-start !important;\n}\n\n.justify-content-end {\n -ms-flex-pack: end !important;\n justify-content: flex-end !important;\n}\n\n.justify-content-center {\n -ms-flex-pack: center !important;\n justify-content: center !important;\n}\n\n.justify-content-between {\n -ms-flex-pack: justify !important;\n justify-content: space-between !important;\n}\n\n.justify-content-around {\n -ms-flex-pack: distribute !important;\n justify-content: space-around !important;\n}\n\n.align-items-start {\n -ms-flex-align: start !important;\n align-items: flex-start !important;\n}\n\n.align-items-end {\n -ms-flex-align: end !important;\n align-items: flex-end !important;\n}\n\n.align-items-center {\n -ms-flex-align: center !important;\n align-items: center !important;\n}\n\n.align-items-baseline {\n -ms-flex-align: baseline !important;\n align-items: baseline !important;\n}\n\n.align-items-stretch {\n -ms-flex-align: stretch !important;\n align-items: stretch !important;\n}\n\n.align-content-start {\n -ms-flex-line-pack: start !important;\n align-content: flex-start !important;\n}\n\n.align-content-end {\n -ms-flex-line-pack: end !important;\n align-content: flex-end !important;\n}\n\n.align-content-center {\n -ms-flex-line-pack: center !important;\n align-content: center !important;\n}\n\n.align-content-between {\n -ms-flex-line-pack: justify !important;\n align-content: space-between !important;\n}\n\n.align-content-around {\n -ms-flex-line-pack: distribute !important;\n align-content: space-around !important;\n}\n\n.align-content-stretch {\n -ms-flex-line-pack: stretch !important;\n align-content: stretch !important;\n}\n\n.align-self-auto {\n -ms-flex-item-align: auto !important;\n align-self: auto !important;\n}\n\n.align-self-start {\n -ms-flex-item-align: start !important;\n align-self: flex-start !important;\n}\n\n.align-self-end {\n -ms-flex-item-align: end !important;\n align-self: flex-end !important;\n}\n\n.align-self-center {\n -ms-flex-item-align: center !important;\n align-self: center !important;\n}\n\n.align-self-baseline {\n -ms-flex-item-align: baseline !important;\n align-self: baseline !important;\n}\n\n.align-self-stretch {\n -ms-flex-item-align: stretch !important;\n align-self: stretch !important;\n}\n\n@media (min-width: 576px) {\n .flex-sm-row {\n -ms-flex-direction: row !important;\n flex-direction: row !important;\n }\n .flex-sm-column {\n -ms-flex-direction: column !important;\n flex-direction: column !important;\n }\n .flex-sm-row-reverse {\n -ms-flex-direction: row-reverse !important;\n flex-direction: row-reverse !important;\n }\n .flex-sm-column-reverse {\n -ms-flex-direction: column-reverse !important;\n flex-direction: column-reverse !important;\n }\n .flex-sm-wrap {\n -ms-flex-wrap: wrap !important;\n flex-wrap: wrap !important;\n }\n .flex-sm-nowrap {\n -ms-flex-wrap: nowrap !important;\n flex-wrap: nowrap !important;\n }\n .flex-sm-wrap-reverse {\n -ms-flex-wrap: wrap-reverse !important;\n flex-wrap: wrap-reverse !important;\n }\n .flex-sm-fill {\n -ms-flex: 1 1 auto !important;\n flex: 1 1 auto !important;\n }\n .flex-sm-grow-0 {\n -ms-flex-positive: 0 !important;\n flex-grow: 0 !important;\n }\n .flex-sm-grow-1 {\n -ms-flex-positive: 1 !important;\n flex-grow: 1 !important;\n }\n .flex-sm-shrink-0 {\n -ms-flex-negative: 0 !important;\n flex-shrink: 0 !important;\n }\n .flex-sm-shrink-1 {\n -ms-flex-negative: 1 !important;\n flex-shrink: 1 !important;\n }\n .justify-content-sm-start {\n -ms-flex-pack: start !important;\n justify-content: flex-start !important;\n }\n .justify-content-sm-end {\n -ms-flex-pack: end !important;\n justify-content: flex-end !important;\n }\n .justify-content-sm-center {\n -ms-flex-pack: center !important;\n justify-content: center !important;\n }\n .justify-content-sm-between {\n -ms-flex-pack: justify !important;\n justify-content: space-between !important;\n }\n .justify-content-sm-around {\n -ms-flex-pack: distribute !important;\n justify-content: space-around !important;\n }\n .align-items-sm-start {\n -ms-flex-align: start !important;\n align-items: flex-start !important;\n }\n .align-items-sm-end {\n -ms-flex-align: end !important;\n align-items: flex-end !important;\n }\n .align-items-sm-center {\n -ms-flex-align: center !important;\n align-items: center !important;\n }\n .align-items-sm-baseline {\n -ms-flex-align: baseline !important;\n align-items: baseline !important;\n }\n .align-items-sm-stretch {\n -ms-flex-align: stretch !important;\n align-items: stretch !important;\n }\n .align-content-sm-start {\n -ms-flex-line-pack: start !important;\n align-content: flex-start !important;\n }\n .align-content-sm-end {\n -ms-flex-line-pack: end !important;\n align-content: flex-end !important;\n }\n .align-content-sm-center {\n -ms-flex-line-pack: center !important;\n align-content: center !important;\n }\n .align-content-sm-between {\n -ms-flex-line-pack: justify !important;\n align-content: space-between !important;\n }\n .align-content-sm-around {\n -ms-flex-line-pack: distribute !important;\n align-content: space-around !important;\n }\n .align-content-sm-stretch {\n -ms-flex-line-pack: stretch !important;\n align-content: stretch !important;\n }\n .align-self-sm-auto {\n -ms-flex-item-align: auto !important;\n align-self: auto !important;\n }\n .align-self-sm-start {\n -ms-flex-item-align: start !important;\n align-self: flex-start !important;\n }\n .align-self-sm-end {\n -ms-flex-item-align: end !important;\n align-self: flex-end !important;\n }\n .align-self-sm-center {\n -ms-flex-item-align: center !important;\n align-self: center !important;\n }\n .align-self-sm-baseline {\n -ms-flex-item-align: baseline !important;\n align-self: baseline !important;\n }\n .align-self-sm-stretch {\n -ms-flex-item-align: stretch !important;\n align-self: stretch !important;\n }\n}\n\n@media (min-width: 768px) {\n .flex-md-row {\n -ms-flex-direction: row !important;\n flex-direction: row !important;\n }\n .flex-md-column {\n -ms-flex-direction: column !important;\n flex-direction: column !important;\n }\n .flex-md-row-reverse {\n -ms-flex-direction: row-reverse !important;\n flex-direction: row-reverse !important;\n }\n .flex-md-column-reverse {\n -ms-flex-direction: column-reverse !important;\n flex-direction: column-reverse !important;\n }\n .flex-md-wrap {\n -ms-flex-wrap: wrap !important;\n flex-wrap: wrap !important;\n }\n .flex-md-nowrap {\n -ms-flex-wrap: nowrap !important;\n flex-wrap: nowrap !important;\n }\n .flex-md-wrap-reverse {\n -ms-flex-wrap: wrap-reverse !important;\n flex-wrap: wrap-reverse !important;\n }\n .flex-md-fill {\n -ms-flex: 1 1 auto !important;\n flex: 1 1 auto !important;\n }\n .flex-md-grow-0 {\n -ms-flex-positive: 0 !important;\n flex-grow: 0 !important;\n }\n .flex-md-grow-1 {\n -ms-flex-positive: 1 !important;\n flex-grow: 1 !important;\n }\n .flex-md-shrink-0 {\n -ms-flex-negative: 0 !important;\n flex-shrink: 0 !important;\n }\n .flex-md-shrink-1 {\n -ms-flex-negative: 1 !important;\n flex-shrink: 1 !important;\n }\n .justify-content-md-start {\n -ms-flex-pack: start !important;\n justify-content: flex-start !important;\n }\n .justify-content-md-end {\n -ms-flex-pack: end !important;\n justify-content: flex-end !important;\n }\n .justify-content-md-center {\n -ms-flex-pack: center !important;\n justify-content: center !important;\n }\n .justify-content-md-between {\n -ms-flex-pack: justify !important;\n justify-content: space-between !important;\n }\n .justify-content-md-around {\n -ms-flex-pack: distribute !important;\n justify-content: space-around !important;\n }\n .align-items-md-start {\n -ms-flex-align: start !important;\n align-items: flex-start !important;\n }\n .align-items-md-end {\n -ms-flex-align: end !important;\n align-items: flex-end !important;\n }\n .align-items-md-center {\n -ms-flex-align: center !important;\n align-items: center !important;\n }\n .align-items-md-baseline {\n -ms-flex-align: baseline !important;\n align-items: baseline !important;\n }\n .align-items-md-stretch {\n -ms-flex-align: stretch !important;\n align-items: stretch !important;\n }\n .align-content-md-start {\n -ms-flex-line-pack: start !important;\n align-content: flex-start !important;\n }\n .align-content-md-end {\n -ms-flex-line-pack: end !important;\n align-content: flex-end !important;\n }\n .align-content-md-center {\n -ms-flex-line-pack: center !important;\n align-content: center !important;\n }\n .align-content-md-between {\n -ms-flex-line-pack: justify !important;\n align-content: space-between !important;\n }\n .align-content-md-around {\n -ms-flex-line-pack: distribute !important;\n align-content: space-around !important;\n }\n .align-content-md-stretch {\n -ms-flex-line-pack: stretch !important;\n align-content: stretch !important;\n }\n .align-self-md-auto {\n -ms-flex-item-align: auto !important;\n align-self: auto !important;\n }\n .align-self-md-start {\n -ms-flex-item-align: start !important;\n align-self: flex-start !important;\n }\n .align-self-md-end {\n -ms-flex-item-align: end !important;\n align-self: flex-end !important;\n }\n .align-self-md-center {\n -ms-flex-item-align: center !important;\n align-self: center !important;\n }\n .align-self-md-baseline {\n -ms-flex-item-align: baseline !important;\n align-self: baseline !important;\n }\n .align-self-md-stretch {\n -ms-flex-item-align: stretch !important;\n align-self: stretch !important;\n }\n}\n\n@media (min-width: 992px) {\n .flex-lg-row {\n -ms-flex-direction: row !important;\n flex-direction: row !important;\n }\n .flex-lg-column {\n -ms-flex-direction: column !important;\n flex-direction: column !important;\n }\n .flex-lg-row-reverse {\n -ms-flex-direction: row-reverse !important;\n flex-direction: row-reverse !important;\n }\n .flex-lg-column-reverse {\n -ms-flex-direction: column-reverse !important;\n flex-direction: column-reverse !important;\n }\n .flex-lg-wrap {\n -ms-flex-wrap: wrap !important;\n flex-wrap: wrap !important;\n }\n .flex-lg-nowrap {\n -ms-flex-wrap: nowrap !important;\n flex-wrap: nowrap !important;\n }\n .flex-lg-wrap-reverse {\n -ms-flex-wrap: wrap-reverse !important;\n flex-wrap: wrap-reverse !important;\n }\n .flex-lg-fill {\n -ms-flex: 1 1 auto !important;\n flex: 1 1 auto !important;\n }\n .flex-lg-grow-0 {\n -ms-flex-positive: 0 !important;\n flex-grow: 0 !important;\n }\n .flex-lg-grow-1 {\n -ms-flex-positive: 1 !important;\n flex-grow: 1 !important;\n }\n .flex-lg-shrink-0 {\n -ms-flex-negative: 0 !important;\n flex-shrink: 0 !important;\n }\n .flex-lg-shrink-1 {\n -ms-flex-negative: 1 !important;\n flex-shrink: 1 !important;\n }\n .justify-content-lg-start {\n -ms-flex-pack: start !important;\n justify-content: flex-start !important;\n }\n .justify-content-lg-end {\n -ms-flex-pack: end !important;\n justify-content: flex-end !important;\n }\n .justify-content-lg-center {\n -ms-flex-pack: center !important;\n justify-content: center !important;\n }\n .justify-content-lg-between {\n -ms-flex-pack: justify !important;\n justify-content: space-between !important;\n }\n .justify-content-lg-around {\n -ms-flex-pack: distribute !important;\n justify-content: space-around !important;\n }\n .align-items-lg-start {\n -ms-flex-align: start !important;\n align-items: flex-start !important;\n }\n .align-items-lg-end {\n -ms-flex-align: end !important;\n align-items: flex-end !important;\n }\n .align-items-lg-center {\n -ms-flex-align: center !important;\n align-items: center !important;\n }\n .align-items-lg-baseline {\n -ms-flex-align: baseline !important;\n align-items: baseline !important;\n }\n .align-items-lg-stretch {\n -ms-flex-align: stretch !important;\n align-items: stretch !important;\n }\n .align-content-lg-start {\n -ms-flex-line-pack: start !important;\n align-content: flex-start !important;\n }\n .align-content-lg-end {\n -ms-flex-line-pack: end !important;\n align-content: flex-end !important;\n }\n .align-content-lg-center {\n -ms-flex-line-pack: center !important;\n align-content: center !important;\n }\n .align-content-lg-between {\n -ms-flex-line-pack: justify !important;\n align-content: space-between !important;\n }\n .align-content-lg-around {\n -ms-flex-line-pack: distribute !important;\n align-content: space-around !important;\n }\n .align-content-lg-stretch {\n -ms-flex-line-pack: stretch !important;\n align-content: stretch !important;\n }\n .align-self-lg-auto {\n -ms-flex-item-align: auto !important;\n align-self: auto !important;\n }\n .align-self-lg-start {\n -ms-flex-item-align: start !important;\n align-self: flex-start !important;\n }\n .align-self-lg-end {\n -ms-flex-item-align: end !important;\n align-self: flex-end !important;\n }\n .align-self-lg-center {\n -ms-flex-item-align: center !important;\n align-self: center !important;\n }\n .align-self-lg-baseline {\n -ms-flex-item-align: baseline !important;\n align-self: baseline !important;\n }\n .align-self-lg-stretch {\n -ms-flex-item-align: stretch !important;\n align-self: stretch !important;\n }\n}\n\n@media (min-width: 1200px) {\n .flex-xl-row {\n -ms-flex-direction: row !important;\n flex-direction: row !important;\n }\n .flex-xl-column {\n -ms-flex-direction: column !important;\n flex-direction: column !important;\n }\n .flex-xl-row-reverse {\n -ms-flex-direction: row-reverse !important;\n flex-direction: row-reverse !important;\n }\n .flex-xl-column-reverse {\n -ms-flex-direction: column-reverse !important;\n flex-direction: column-reverse !important;\n }\n .flex-xl-wrap {\n -ms-flex-wrap: wrap !important;\n flex-wrap: wrap !important;\n }\n .flex-xl-nowrap {\n -ms-flex-wrap: nowrap !important;\n flex-wrap: nowrap !important;\n }\n .flex-xl-wrap-reverse {\n -ms-flex-wrap: wrap-reverse !important;\n flex-wrap: wrap-reverse !important;\n }\n .flex-xl-fill {\n -ms-flex: 1 1 auto !important;\n flex: 1 1 auto !important;\n }\n .flex-xl-grow-0 {\n -ms-flex-positive: 0 !important;\n flex-grow: 0 !important;\n }\n .flex-xl-grow-1 {\n -ms-flex-positive: 1 !important;\n flex-grow: 1 !important;\n }\n .flex-xl-shrink-0 {\n -ms-flex-negative: 0 !important;\n flex-shrink: 0 !important;\n }\n .flex-xl-shrink-1 {\n -ms-flex-negative: 1 !important;\n flex-shrink: 1 !important;\n }\n .justify-content-xl-start {\n -ms-flex-pack: start !important;\n justify-content: flex-start !important;\n }\n .justify-content-xl-end {\n -ms-flex-pack: end !important;\n justify-content: flex-end !important;\n }\n .justify-content-xl-center {\n -ms-flex-pack: center !important;\n justify-content: center !important;\n }\n .justify-content-xl-between {\n -ms-flex-pack: justify !important;\n justify-content: space-between !important;\n }\n .justify-content-xl-around {\n -ms-flex-pack: distribute !important;\n justify-content: space-around !important;\n }\n .align-items-xl-start {\n -ms-flex-align: start !important;\n align-items: flex-start !important;\n }\n .align-items-xl-end {\n -ms-flex-align: end !important;\n align-items: flex-end !important;\n }\n .align-items-xl-center {\n -ms-flex-align: center !important;\n align-items: center !important;\n }\n .align-items-xl-baseline {\n -ms-flex-align: baseline !important;\n align-items: baseline !important;\n }\n .align-items-xl-stretch {\n -ms-flex-align: stretch !important;\n align-items: stretch !important;\n }\n .align-content-xl-start {\n -ms-flex-line-pack: start !important;\n align-content: flex-start !important;\n }\n .align-content-xl-end {\n -ms-flex-line-pack: end !important;\n align-content: flex-end !important;\n }\n .align-content-xl-center {\n -ms-flex-line-pack: center !important;\n align-content: center !important;\n }\n .align-content-xl-between {\n -ms-flex-line-pack: justify !important;\n align-content: space-between !important;\n }\n .align-content-xl-around {\n -ms-flex-line-pack: distribute !important;\n align-content: space-around !important;\n }\n .align-content-xl-stretch {\n -ms-flex-line-pack: stretch !important;\n align-content: stretch !important;\n }\n .align-self-xl-auto {\n -ms-flex-item-align: auto !important;\n align-self: auto !important;\n }\n .align-self-xl-start {\n -ms-flex-item-align: start !important;\n align-self: flex-start !important;\n }\n .align-self-xl-end {\n -ms-flex-item-align: end !important;\n align-self: flex-end !important;\n }\n .align-self-xl-center {\n -ms-flex-item-align: center !important;\n align-self: center !important;\n }\n .align-self-xl-baseline {\n -ms-flex-item-align: baseline !important;\n align-self: baseline !important;\n }\n .align-self-xl-stretch {\n -ms-flex-item-align: stretch !important;\n align-self: stretch !important;\n }\n}\n\n.float-left {\n float: left !important;\n}\n\n.float-right {\n float: right !important;\n}\n\n.float-none {\n float: none !important;\n}\n\n@media (min-width: 576px) {\n .float-sm-left {\n float: left !important;\n }\n .float-sm-right {\n float: right !important;\n }\n .float-sm-none {\n float: none !important;\n }\n}\n\n@media (min-width: 768px) {\n .float-md-left {\n float: left !important;\n }\n .float-md-right {\n float: right !important;\n }\n .float-md-none {\n float: none !important;\n }\n}\n\n@media (min-width: 992px) {\n .float-lg-left {\n float: left !important;\n }\n .float-lg-right {\n float: right !important;\n }\n .float-lg-none {\n float: none !important;\n }\n}\n\n@media (min-width: 1200px) {\n .float-xl-left {\n float: left !important;\n }\n .float-xl-right {\n float: right !important;\n }\n .float-xl-none {\n float: none !important;\n }\n}\n\n.overflow-auto {\n overflow: auto !important;\n}\n\n.overflow-hidden {\n overflow: hidden !important;\n}\n\n.position-static {\n position: static !important;\n}\n\n.position-relative {\n position: relative !important;\n}\n\n.position-absolute {\n position: absolute !important;\n}\n\n.position-fixed {\n position: fixed !important;\n}\n\n.position-sticky {\n position: -webkit-sticky !important;\n position: sticky !important;\n}\n\n.fixed-top {\n position: fixed;\n top: 0;\n right: 0;\n left: 0;\n z-index: 1030;\n}\n\n.fixed-bottom {\n position: fixed;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1030;\n}\n\n@supports ((position: -webkit-sticky) or (position: sticky)) {\n .sticky-top {\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n z-index: 1020;\n }\n}\n\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n\n.sr-only-focusable:active, .sr-only-focusable:focus {\n position: static;\n width: auto;\n height: auto;\n overflow: visible;\n clip: auto;\n white-space: normal;\n}\n\n.shadow-sm {\n box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important;\n}\n\n.shadow {\n box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;\n}\n\n.shadow-lg {\n box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175) !important;\n}\n\n.shadow-none {\n box-shadow: none !important;\n}\n\n.w-25 {\n width: 25% !important;\n}\n\n.w-50 {\n width: 50% !important;\n}\n\n.w-75 {\n width: 75% !important;\n}\n\n.w-100 {\n width: 100% !important;\n}\n\n.w-auto {\n width: auto !important;\n}\n\n.h-25 {\n height: 25% !important;\n}\n\n.h-50 {\n height: 50% !important;\n}\n\n.h-75 {\n height: 75% !important;\n}\n\n.h-100 {\n height: 100% !important;\n}\n\n.h-auto {\n height: auto !important;\n}\n\n.mw-100 {\n max-width: 100% !important;\n}\n\n.mh-100 {\n max-height: 100% !important;\n}\n\n.min-vw-100 {\n min-width: 100vw !important;\n}\n\n.min-vh-100 {\n min-height: 100vh !important;\n}\n\n.vw-100 {\n width: 100vw !important;\n}\n\n.vh-100 {\n height: 100vh !important;\n}\n\n.stretched-link::after {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1;\n pointer-events: auto;\n content: \"\";\n background-color: rgba(0, 0, 0, 0);\n}\n\n.m-0 {\n margin: 0 !important;\n}\n\n.mt-0,\n.my-0 {\n margin-top: 0 !important;\n}\n\n.mr-0,\n.mx-0 {\n margin-right: 0 !important;\n}\n\n.mb-0,\n.my-0 {\n margin-bottom: 0 !important;\n}\n\n.ml-0,\n.mx-0 {\n margin-left: 0 !important;\n}\n\n.m-1 {\n margin: 0.25rem !important;\n}\n\n.mt-1,\n.my-1 {\n margin-top: 0.25rem !important;\n}\n\n.mr-1,\n.mx-1 {\n margin-right: 0.25rem !important;\n}\n\n.mb-1,\n.my-1 {\n margin-bottom: 0.25rem !important;\n}\n\n.ml-1,\n.mx-1 {\n margin-left: 0.25rem !important;\n}\n\n.m-2 {\n margin: 0.5rem !important;\n}\n\n.mt-2,\n.my-2 {\n margin-top: 0.5rem !important;\n}\n\n.mr-2,\n.mx-2 {\n margin-right: 0.5rem !important;\n}\n\n.mb-2,\n.my-2 {\n margin-bottom: 0.5rem !important;\n}\n\n.ml-2,\n.mx-2 {\n margin-left: 0.5rem !important;\n}\n\n.m-3 {\n margin: 1rem !important;\n}\n\n.mt-3,\n.my-3 {\n margin-top: 1rem !important;\n}\n\n.mr-3,\n.mx-3 {\n margin-right: 1rem !important;\n}\n\n.mb-3,\n.my-3 {\n margin-bottom: 1rem !important;\n}\n\n.ml-3,\n.mx-3 {\n margin-left: 1rem !important;\n}\n\n.m-4 {\n margin: 1.5rem !important;\n}\n\n.mt-4,\n.my-4 {\n margin-top: 1.5rem !important;\n}\n\n.mr-4,\n.mx-4 {\n margin-right: 1.5rem !important;\n}\n\n.mb-4,\n.my-4 {\n margin-bottom: 1.5rem !important;\n}\n\n.ml-4,\n.mx-4 {\n margin-left: 1.5rem !important;\n}\n\n.m-5 {\n margin: 3rem !important;\n}\n\n.mt-5,\n.my-5 {\n margin-top: 3rem !important;\n}\n\n.mr-5,\n.mx-5 {\n margin-right: 3rem !important;\n}\n\n.mb-5,\n.my-5 {\n margin-bottom: 3rem !important;\n}\n\n.ml-5,\n.mx-5 {\n margin-left: 3rem !important;\n}\n\n.p-0 {\n padding: 0 !important;\n}\n\n.pt-0,\n.py-0 {\n padding-top: 0 !important;\n}\n\n.pr-0,\n.px-0 {\n padding-right: 0 !important;\n}\n\n.pb-0,\n.py-0 {\n padding-bottom: 0 !important;\n}\n\n.pl-0,\n.px-0 {\n padding-left: 0 !important;\n}\n\n.p-1 {\n padding: 0.25rem !important;\n}\n\n.pt-1,\n.py-1 {\n padding-top: 0.25rem !important;\n}\n\n.pr-1,\n.px-1 {\n padding-right: 0.25rem !important;\n}\n\n.pb-1,\n.py-1 {\n padding-bottom: 0.25rem !important;\n}\n\n.pl-1,\n.px-1 {\n padding-left: 0.25rem !important;\n}\n\n.p-2 {\n padding: 0.5rem !important;\n}\n\n.pt-2,\n.py-2 {\n padding-top: 0.5rem !important;\n}\n\n.pr-2,\n.px-2 {\n padding-right: 0.5rem !important;\n}\n\n.pb-2,\n.py-2 {\n padding-bottom: 0.5rem !important;\n}\n\n.pl-2,\n.px-2 {\n padding-left: 0.5rem !important;\n}\n\n.p-3 {\n padding: 1rem !important;\n}\n\n.pt-3,\n.py-3 {\n padding-top: 1rem !important;\n}\n\n.pr-3,\n.px-3 {\n padding-right: 1rem !important;\n}\n\n.pb-3,\n.py-3 {\n padding-bottom: 1rem !important;\n}\n\n.pl-3,\n.px-3 {\n padding-left: 1rem !important;\n}\n\n.p-4 {\n padding: 1.5rem !important;\n}\n\n.pt-4,\n.py-4 {\n padding-top: 1.5rem !important;\n}\n\n.pr-4,\n.px-4 {\n padding-right: 1.5rem !important;\n}\n\n.pb-4,\n.py-4 {\n padding-bottom: 1.5rem !important;\n}\n\n.pl-4,\n.px-4 {\n padding-left: 1.5rem !important;\n}\n\n.p-5 {\n padding: 3rem !important;\n}\n\n.pt-5,\n.py-5 {\n padding-top: 3rem !important;\n}\n\n.pr-5,\n.px-5 {\n padding-right: 3rem !important;\n}\n\n.pb-5,\n.py-5 {\n padding-bottom: 3rem !important;\n}\n\n.pl-5,\n.px-5 {\n padding-left: 3rem !important;\n}\n\n.m-n1 {\n margin: -0.25rem !important;\n}\n\n.mt-n1,\n.my-n1 {\n margin-top: -0.25rem !important;\n}\n\n.mr-n1,\n.mx-n1 {\n margin-right: -0.25rem !important;\n}\n\n.mb-n1,\n.my-n1 {\n margin-bottom: -0.25rem !important;\n}\n\n.ml-n1,\n.mx-n1 {\n margin-left: -0.25rem !important;\n}\n\n.m-n2 {\n margin: -0.5rem !important;\n}\n\n.mt-n2,\n.my-n2 {\n margin-top: -0.5rem !important;\n}\n\n.mr-n2,\n.mx-n2 {\n margin-right: -0.5rem !important;\n}\n\n.mb-n2,\n.my-n2 {\n margin-bottom: -0.5rem !important;\n}\n\n.ml-n2,\n.mx-n2 {\n margin-left: -0.5rem !important;\n}\n\n.m-n3 {\n margin: -1rem !important;\n}\n\n.mt-n3,\n.my-n3 {\n margin-top: -1rem !important;\n}\n\n.mr-n3,\n.mx-n3 {\n margin-right: -1rem !important;\n}\n\n.mb-n3,\n.my-n3 {\n margin-bottom: -1rem !important;\n}\n\n.ml-n3,\n.mx-n3 {\n margin-left: -1rem !important;\n}\n\n.m-n4 {\n margin: -1.5rem !important;\n}\n\n.mt-n4,\n.my-n4 {\n margin-top: -1.5rem !important;\n}\n\n.mr-n4,\n.mx-n4 {\n margin-right: -1.5rem !important;\n}\n\n.mb-n4,\n.my-n4 {\n margin-bottom: -1.5rem !important;\n}\n\n.ml-n4,\n.mx-n4 {\n margin-left: -1.5rem !important;\n}\n\n.m-n5 {\n margin: -3rem !important;\n}\n\n.mt-n5,\n.my-n5 {\n margin-top: -3rem !important;\n}\n\n.mr-n5,\n.mx-n5 {\n margin-right: -3rem !important;\n}\n\n.mb-n5,\n.my-n5 {\n margin-bottom: -3rem !important;\n}\n\n.ml-n5,\n.mx-n5 {\n margin-left: -3rem !important;\n}\n\n.m-auto {\n margin: auto !important;\n}\n\n.mt-auto,\n.my-auto {\n margin-top: auto !important;\n}\n\n.mr-auto,\n.mx-auto {\n margin-right: auto !important;\n}\n\n.mb-auto,\n.my-auto {\n margin-bottom: auto !important;\n}\n\n.ml-auto,\n.mx-auto {\n margin-left: auto !important;\n}\n\n@media (min-width: 576px) {\n .m-sm-0 {\n margin: 0 !important;\n }\n .mt-sm-0,\n .my-sm-0 {\n margin-top: 0 !important;\n }\n .mr-sm-0,\n .mx-sm-0 {\n margin-right: 0 !important;\n }\n .mb-sm-0,\n .my-sm-0 {\n margin-bottom: 0 !important;\n }\n .ml-sm-0,\n .mx-sm-0 {\n margin-left: 0 !important;\n }\n .m-sm-1 {\n margin: 0.25rem !important;\n }\n .mt-sm-1,\n .my-sm-1 {\n margin-top: 0.25rem !important;\n }\n .mr-sm-1,\n .mx-sm-1 {\n margin-right: 0.25rem !important;\n }\n .mb-sm-1,\n .my-sm-1 {\n margin-bottom: 0.25rem !important;\n }\n .ml-sm-1,\n .mx-sm-1 {\n margin-left: 0.25rem !important;\n }\n .m-sm-2 {\n margin: 0.5rem !important;\n }\n .mt-sm-2,\n .my-sm-2 {\n margin-top: 0.5rem !important;\n }\n .mr-sm-2,\n .mx-sm-2 {\n margin-right: 0.5rem !important;\n }\n .mb-sm-2,\n .my-sm-2 {\n margin-bottom: 0.5rem !important;\n }\n .ml-sm-2,\n .mx-sm-2 {\n margin-left: 0.5rem !important;\n }\n .m-sm-3 {\n margin: 1rem !important;\n }\n .mt-sm-3,\n .my-sm-3 {\n margin-top: 1rem !important;\n }\n .mr-sm-3,\n .mx-sm-3 {\n margin-right: 1rem !important;\n }\n .mb-sm-3,\n .my-sm-3 {\n margin-bottom: 1rem !important;\n }\n .ml-sm-3,\n .mx-sm-3 {\n margin-left: 1rem !important;\n }\n .m-sm-4 {\n margin: 1.5rem !important;\n }\n .mt-sm-4,\n .my-sm-4 {\n margin-top: 1.5rem !important;\n }\n .mr-sm-4,\n .mx-sm-4 {\n margin-right: 1.5rem !important;\n }\n .mb-sm-4,\n .my-sm-4 {\n margin-bottom: 1.5rem !important;\n }\n .ml-sm-4,\n .mx-sm-4 {\n margin-left: 1.5rem !important;\n }\n .m-sm-5 {\n margin: 3rem !important;\n }\n .mt-sm-5,\n .my-sm-5 {\n margin-top: 3rem !important;\n }\n .mr-sm-5,\n .mx-sm-5 {\n margin-right: 3rem !important;\n }\n .mb-sm-5,\n .my-sm-5 {\n margin-bottom: 3rem !important;\n }\n .ml-sm-5,\n .mx-sm-5 {\n margin-left: 3rem !important;\n }\n .p-sm-0 {\n padding: 0 !important;\n }\n .pt-sm-0,\n .py-sm-0 {\n padding-top: 0 !important;\n }\n .pr-sm-0,\n .px-sm-0 {\n padding-right: 0 !important;\n }\n .pb-sm-0,\n .py-sm-0 {\n padding-bottom: 0 !important;\n }\n .pl-sm-0,\n .px-sm-0 {\n padding-left: 0 !important;\n }\n .p-sm-1 {\n padding: 0.25rem !important;\n }\n .pt-sm-1,\n .py-sm-1 {\n padding-top: 0.25rem !important;\n }\n .pr-sm-1,\n .px-sm-1 {\n padding-right: 0.25rem !important;\n }\n .pb-sm-1,\n .py-sm-1 {\n padding-bottom: 0.25rem !important;\n }\n .pl-sm-1,\n .px-sm-1 {\n padding-left: 0.25rem !important;\n }\n .p-sm-2 {\n padding: 0.5rem !important;\n }\n .pt-sm-2,\n .py-sm-2 {\n padding-top: 0.5rem !important;\n }\n .pr-sm-2,\n .px-sm-2 {\n padding-right: 0.5rem !important;\n }\n .pb-sm-2,\n .py-sm-2 {\n padding-bottom: 0.5rem !important;\n }\n .pl-sm-2,\n .px-sm-2 {\n padding-left: 0.5rem !important;\n }\n .p-sm-3 {\n padding: 1rem !important;\n }\n .pt-sm-3,\n .py-sm-3 {\n padding-top: 1rem !important;\n }\n .pr-sm-3,\n .px-sm-3 {\n padding-right: 1rem !important;\n }\n .pb-sm-3,\n .py-sm-3 {\n padding-bottom: 1rem !important;\n }\n .pl-sm-3,\n .px-sm-3 {\n padding-left: 1rem !important;\n }\n .p-sm-4 {\n padding: 1.5rem !important;\n }\n .pt-sm-4,\n .py-sm-4 {\n padding-top: 1.5rem !important;\n }\n .pr-sm-4,\n .px-sm-4 {\n padding-right: 1.5rem !important;\n }\n .pb-sm-4,\n .py-sm-4 {\n padding-bottom: 1.5rem !important;\n }\n .pl-sm-4,\n .px-sm-4 {\n padding-left: 1.5rem !important;\n }\n .p-sm-5 {\n padding: 3rem !important;\n }\n .pt-sm-5,\n .py-sm-5 {\n padding-top: 3rem !important;\n }\n .pr-sm-5,\n .px-sm-5 {\n padding-right: 3rem !important;\n }\n .pb-sm-5,\n .py-sm-5 {\n padding-bottom: 3rem !important;\n }\n .pl-sm-5,\n .px-sm-5 {\n padding-left: 3rem !important;\n }\n .m-sm-n1 {\n margin: -0.25rem !important;\n }\n .mt-sm-n1,\n .my-sm-n1 {\n margin-top: -0.25rem !important;\n }\n .mr-sm-n1,\n .mx-sm-n1 {\n margin-right: -0.25rem !important;\n }\n .mb-sm-n1,\n .my-sm-n1 {\n margin-bottom: -0.25rem !important;\n }\n .ml-sm-n1,\n .mx-sm-n1 {\n margin-left: -0.25rem !important;\n }\n .m-sm-n2 {\n margin: -0.5rem !important;\n }\n .mt-sm-n2,\n .my-sm-n2 {\n margin-top: -0.5rem !important;\n }\n .mr-sm-n2,\n .mx-sm-n2 {\n margin-right: -0.5rem !important;\n }\n .mb-sm-n2,\n .my-sm-n2 {\n margin-bottom: -0.5rem !important;\n }\n .ml-sm-n2,\n .mx-sm-n2 {\n margin-left: -0.5rem !important;\n }\n .m-sm-n3 {\n margin: -1rem !important;\n }\n .mt-sm-n3,\n .my-sm-n3 {\n margin-top: -1rem !important;\n }\n .mr-sm-n3,\n .mx-sm-n3 {\n margin-right: -1rem !important;\n }\n .mb-sm-n3,\n .my-sm-n3 {\n margin-bottom: -1rem !important;\n }\n .ml-sm-n3,\n .mx-sm-n3 {\n margin-left: -1rem !important;\n }\n .m-sm-n4 {\n margin: -1.5rem !important;\n }\n .mt-sm-n4,\n .my-sm-n4 {\n margin-top: -1.5rem !important;\n }\n .mr-sm-n4,\n .mx-sm-n4 {\n margin-right: -1.5rem !important;\n }\n .mb-sm-n4,\n .my-sm-n4 {\n margin-bottom: -1.5rem !important;\n }\n .ml-sm-n4,\n .mx-sm-n4 {\n margin-left: -1.5rem !important;\n }\n .m-sm-n5 {\n margin: -3rem !important;\n }\n .mt-sm-n5,\n .my-sm-n5 {\n margin-top: -3rem !important;\n }\n .mr-sm-n5,\n .mx-sm-n5 {\n margin-right: -3rem !important;\n }\n .mb-sm-n5,\n .my-sm-n5 {\n margin-bottom: -3rem !important;\n }\n .ml-sm-n5,\n .mx-sm-n5 {\n margin-left: -3rem !important;\n }\n .m-sm-auto {\n margin: auto !important;\n }\n .mt-sm-auto,\n .my-sm-auto {\n margin-top: auto !important;\n }\n .mr-sm-auto,\n .mx-sm-auto {\n margin-right: auto !important;\n }\n .mb-sm-auto,\n .my-sm-auto {\n margin-bottom: auto !important;\n }\n .ml-sm-auto,\n .mx-sm-auto {\n margin-left: auto !important;\n }\n}\n\n@media (min-width: 768px) {\n .m-md-0 {\n margin: 0 !important;\n }\n .mt-md-0,\n .my-md-0 {\n margin-top: 0 !important;\n }\n .mr-md-0,\n .mx-md-0 {\n margin-right: 0 !important;\n }\n .mb-md-0,\n .my-md-0 {\n margin-bottom: 0 !important;\n }\n .ml-md-0,\n .mx-md-0 {\n margin-left: 0 !important;\n }\n .m-md-1 {\n margin: 0.25rem !important;\n }\n .mt-md-1,\n .my-md-1 {\n margin-top: 0.25rem !important;\n }\n .mr-md-1,\n .mx-md-1 {\n margin-right: 0.25rem !important;\n }\n .mb-md-1,\n .my-md-1 {\n margin-bottom: 0.25rem !important;\n }\n .ml-md-1,\n .mx-md-1 {\n margin-left: 0.25rem !important;\n }\n .m-md-2 {\n margin: 0.5rem !important;\n }\n .mt-md-2,\n .my-md-2 {\n margin-top: 0.5rem !important;\n }\n .mr-md-2,\n .mx-md-2 {\n margin-right: 0.5rem !important;\n }\n .mb-md-2,\n .my-md-2 {\n margin-bottom: 0.5rem !important;\n }\n .ml-md-2,\n .mx-md-2 {\n margin-left: 0.5rem !important;\n }\n .m-md-3 {\n margin: 1rem !important;\n }\n .mt-md-3,\n .my-md-3 {\n margin-top: 1rem !important;\n }\n .mr-md-3,\n .mx-md-3 {\n margin-right: 1rem !important;\n }\n .mb-md-3,\n .my-md-3 {\n margin-bottom: 1rem !important;\n }\n .ml-md-3,\n .mx-md-3 {\n margin-left: 1rem !important;\n }\n .m-md-4 {\n margin: 1.5rem !important;\n }\n .mt-md-4,\n .my-md-4 {\n margin-top: 1.5rem !important;\n }\n .mr-md-4,\n .mx-md-4 {\n margin-right: 1.5rem !important;\n }\n .mb-md-4,\n .my-md-4 {\n margin-bottom: 1.5rem !important;\n }\n .ml-md-4,\n .mx-md-4 {\n margin-left: 1.5rem !important;\n }\n .m-md-5 {\n margin: 3rem !important;\n }\n .mt-md-5,\n .my-md-5 {\n margin-top: 3rem !important;\n }\n .mr-md-5,\n .mx-md-5 {\n margin-right: 3rem !important;\n }\n .mb-md-5,\n .my-md-5 {\n margin-bottom: 3rem !important;\n }\n .ml-md-5,\n .mx-md-5 {\n margin-left: 3rem !important;\n }\n .p-md-0 {\n padding: 0 !important;\n }\n .pt-md-0,\n .py-md-0 {\n padding-top: 0 !important;\n }\n .pr-md-0,\n .px-md-0 {\n padding-right: 0 !important;\n }\n .pb-md-0,\n .py-md-0 {\n padding-bottom: 0 !important;\n }\n .pl-md-0,\n .px-md-0 {\n padding-left: 0 !important;\n }\n .p-md-1 {\n padding: 0.25rem !important;\n }\n .pt-md-1,\n .py-md-1 {\n padding-top: 0.25rem !important;\n }\n .pr-md-1,\n .px-md-1 {\n padding-right: 0.25rem !important;\n }\n .pb-md-1,\n .py-md-1 {\n padding-bottom: 0.25rem !important;\n }\n .pl-md-1,\n .px-md-1 {\n padding-left: 0.25rem !important;\n }\n .p-md-2 {\n padding: 0.5rem !important;\n }\n .pt-md-2,\n .py-md-2 {\n padding-top: 0.5rem !important;\n }\n .pr-md-2,\n .px-md-2 {\n padding-right: 0.5rem !important;\n }\n .pb-md-2,\n .py-md-2 {\n padding-bottom: 0.5rem !important;\n }\n .pl-md-2,\n .px-md-2 {\n padding-left: 0.5rem !important;\n }\n .p-md-3 {\n padding: 1rem !important;\n }\n .pt-md-3,\n .py-md-3 {\n padding-top: 1rem !important;\n }\n .pr-md-3,\n .px-md-3 {\n padding-right: 1rem !important;\n }\n .pb-md-3,\n .py-md-3 {\n padding-bottom: 1rem !important;\n }\n .pl-md-3,\n .px-md-3 {\n padding-left: 1rem !important;\n }\n .p-md-4 {\n padding: 1.5rem !important;\n }\n .pt-md-4,\n .py-md-4 {\n padding-top: 1.5rem !important;\n }\n .pr-md-4,\n .px-md-4 {\n padding-right: 1.5rem !important;\n }\n .pb-md-4,\n .py-md-4 {\n padding-bottom: 1.5rem !important;\n }\n .pl-md-4,\n .px-md-4 {\n padding-left: 1.5rem !important;\n }\n .p-md-5 {\n padding: 3rem !important;\n }\n .pt-md-5,\n .py-md-5 {\n padding-top: 3rem !important;\n }\n .pr-md-5,\n .px-md-5 {\n padding-right: 3rem !important;\n }\n .pb-md-5,\n .py-md-5 {\n padding-bottom: 3rem !important;\n }\n .pl-md-5,\n .px-md-5 {\n padding-left: 3rem !important;\n }\n .m-md-n1 {\n margin: -0.25rem !important;\n }\n .mt-md-n1,\n .my-md-n1 {\n margin-top: -0.25rem !important;\n }\n .mr-md-n1,\n .mx-md-n1 {\n margin-right: -0.25rem !important;\n }\n .mb-md-n1,\n .my-md-n1 {\n margin-bottom: -0.25rem !important;\n }\n .ml-md-n1,\n .mx-md-n1 {\n margin-left: -0.25rem !important;\n }\n .m-md-n2 {\n margin: -0.5rem !important;\n }\n .mt-md-n2,\n .my-md-n2 {\n margin-top: -0.5rem !important;\n }\n .mr-md-n2,\n .mx-md-n2 {\n margin-right: -0.5rem !important;\n }\n .mb-md-n2,\n .my-md-n2 {\n margin-bottom: -0.5rem !important;\n }\n .ml-md-n2,\n .mx-md-n2 {\n margin-left: -0.5rem !important;\n }\n .m-md-n3 {\n margin: -1rem !important;\n }\n .mt-md-n3,\n .my-md-n3 {\n margin-top: -1rem !important;\n }\n .mr-md-n3,\n .mx-md-n3 {\n margin-right: -1rem !important;\n }\n .mb-md-n3,\n .my-md-n3 {\n margin-bottom: -1rem !important;\n }\n .ml-md-n3,\n .mx-md-n3 {\n margin-left: -1rem !important;\n }\n .m-md-n4 {\n margin: -1.5rem !important;\n }\n .mt-md-n4,\n .my-md-n4 {\n margin-top: -1.5rem !important;\n }\n .mr-md-n4,\n .mx-md-n4 {\n margin-right: -1.5rem !important;\n }\n .mb-md-n4,\n .my-md-n4 {\n margin-bottom: -1.5rem !important;\n }\n .ml-md-n4,\n .mx-md-n4 {\n margin-left: -1.5rem !important;\n }\n .m-md-n5 {\n margin: -3rem !important;\n }\n .mt-md-n5,\n .my-md-n5 {\n margin-top: -3rem !important;\n }\n .mr-md-n5,\n .mx-md-n5 {\n margin-right: -3rem !important;\n }\n .mb-md-n5,\n .my-md-n5 {\n margin-bottom: -3rem !important;\n }\n .ml-md-n5,\n .mx-md-n5 {\n margin-left: -3rem !important;\n }\n .m-md-auto {\n margin: auto !important;\n }\n .mt-md-auto,\n .my-md-auto {\n margin-top: auto !important;\n }\n .mr-md-auto,\n .mx-md-auto {\n margin-right: auto !important;\n }\n .mb-md-auto,\n .my-md-auto {\n margin-bottom: auto !important;\n }\n .ml-md-auto,\n .mx-md-auto {\n margin-left: auto !important;\n }\n}\n\n@media (min-width: 992px) {\n .m-lg-0 {\n margin: 0 !important;\n }\n .mt-lg-0,\n .my-lg-0 {\n margin-top: 0 !important;\n }\n .mr-lg-0,\n .mx-lg-0 {\n margin-right: 0 !important;\n }\n .mb-lg-0,\n .my-lg-0 {\n margin-bottom: 0 !important;\n }\n .ml-lg-0,\n .mx-lg-0 {\n margin-left: 0 !important;\n }\n .m-lg-1 {\n margin: 0.25rem !important;\n }\n .mt-lg-1,\n .my-lg-1 {\n margin-top: 0.25rem !important;\n }\n .mr-lg-1,\n .mx-lg-1 {\n margin-right: 0.25rem !important;\n }\n .mb-lg-1,\n .my-lg-1 {\n margin-bottom: 0.25rem !important;\n }\n .ml-lg-1,\n .mx-lg-1 {\n margin-left: 0.25rem !important;\n }\n .m-lg-2 {\n margin: 0.5rem !important;\n }\n .mt-lg-2,\n .my-lg-2 {\n margin-top: 0.5rem !important;\n }\n .mr-lg-2,\n .mx-lg-2 {\n margin-right: 0.5rem !important;\n }\n .mb-lg-2,\n .my-lg-2 {\n margin-bottom: 0.5rem !important;\n }\n .ml-lg-2,\n .mx-lg-2 {\n margin-left: 0.5rem !important;\n }\n .m-lg-3 {\n margin: 1rem !important;\n }\n .mt-lg-3,\n .my-lg-3 {\n margin-top: 1rem !important;\n }\n .mr-lg-3,\n .mx-lg-3 {\n margin-right: 1rem !important;\n }\n .mb-lg-3,\n .my-lg-3 {\n margin-bottom: 1rem !important;\n }\n .ml-lg-3,\n .mx-lg-3 {\n margin-left: 1rem !important;\n }\n .m-lg-4 {\n margin: 1.5rem !important;\n }\n .mt-lg-4,\n .my-lg-4 {\n margin-top: 1.5rem !important;\n }\n .mr-lg-4,\n .mx-lg-4 {\n margin-right: 1.5rem !important;\n }\n .mb-lg-4,\n .my-lg-4 {\n margin-bottom: 1.5rem !important;\n }\n .ml-lg-4,\n .mx-lg-4 {\n margin-left: 1.5rem !important;\n }\n .m-lg-5 {\n margin: 3rem !important;\n }\n .mt-lg-5,\n .my-lg-5 {\n margin-top: 3rem !important;\n }\n .mr-lg-5,\n .mx-lg-5 {\n margin-right: 3rem !important;\n }\n .mb-lg-5,\n .my-lg-5 {\n margin-bottom: 3rem !important;\n }\n .ml-lg-5,\n .mx-lg-5 {\n margin-left: 3rem !important;\n }\n .p-lg-0 {\n padding: 0 !important;\n }\n .pt-lg-0,\n .py-lg-0 {\n padding-top: 0 !important;\n }\n .pr-lg-0,\n .px-lg-0 {\n padding-right: 0 !important;\n }\n .pb-lg-0,\n .py-lg-0 {\n padding-bottom: 0 !important;\n }\n .pl-lg-0,\n .px-lg-0 {\n padding-left: 0 !important;\n }\n .p-lg-1 {\n padding: 0.25rem !important;\n }\n .pt-lg-1,\n .py-lg-1 {\n padding-top: 0.25rem !important;\n }\n .pr-lg-1,\n .px-lg-1 {\n padding-right: 0.25rem !important;\n }\n .pb-lg-1,\n .py-lg-1 {\n padding-bottom: 0.25rem !important;\n }\n .pl-lg-1,\n .px-lg-1 {\n padding-left: 0.25rem !important;\n }\n .p-lg-2 {\n padding: 0.5rem !important;\n }\n .pt-lg-2,\n .py-lg-2 {\n padding-top: 0.5rem !important;\n }\n .pr-lg-2,\n .px-lg-2 {\n padding-right: 0.5rem !important;\n }\n .pb-lg-2,\n .py-lg-2 {\n padding-bottom: 0.5rem !important;\n }\n .pl-lg-2,\n .px-lg-2 {\n padding-left: 0.5rem !important;\n }\n .p-lg-3 {\n padding: 1rem !important;\n }\n .pt-lg-3,\n .py-lg-3 {\n padding-top: 1rem !important;\n }\n .pr-lg-3,\n .px-lg-3 {\n padding-right: 1rem !important;\n }\n .pb-lg-3,\n .py-lg-3 {\n padding-bottom: 1rem !important;\n }\n .pl-lg-3,\n .px-lg-3 {\n padding-left: 1rem !important;\n }\n .p-lg-4 {\n padding: 1.5rem !important;\n }\n .pt-lg-4,\n .py-lg-4 {\n padding-top: 1.5rem !important;\n }\n .pr-lg-4,\n .px-lg-4 {\n padding-right: 1.5rem !important;\n }\n .pb-lg-4,\n .py-lg-4 {\n padding-bottom: 1.5rem !important;\n }\n .pl-lg-4,\n .px-lg-4 {\n padding-left: 1.5rem !important;\n }\n .p-lg-5 {\n padding: 3rem !important;\n }\n .pt-lg-5,\n .py-lg-5 {\n padding-top: 3rem !important;\n }\n .pr-lg-5,\n .px-lg-5 {\n padding-right: 3rem !important;\n }\n .pb-lg-5,\n .py-lg-5 {\n padding-bottom: 3rem !important;\n }\n .pl-lg-5,\n .px-lg-5 {\n padding-left: 3rem !important;\n }\n .m-lg-n1 {\n margin: -0.25rem !important;\n }\n .mt-lg-n1,\n .my-lg-n1 {\n margin-top: -0.25rem !important;\n }\n .mr-lg-n1,\n .mx-lg-n1 {\n margin-right: -0.25rem !important;\n }\n .mb-lg-n1,\n .my-lg-n1 {\n margin-bottom: -0.25rem !important;\n }\n .ml-lg-n1,\n .mx-lg-n1 {\n margin-left: -0.25rem !important;\n }\n .m-lg-n2 {\n margin: -0.5rem !important;\n }\n .mt-lg-n2,\n .my-lg-n2 {\n margin-top: -0.5rem !important;\n }\n .mr-lg-n2,\n .mx-lg-n2 {\n margin-right: -0.5rem !important;\n }\n .mb-lg-n2,\n .my-lg-n2 {\n margin-bottom: -0.5rem !important;\n }\n .ml-lg-n2,\n .mx-lg-n2 {\n margin-left: -0.5rem !important;\n }\n .m-lg-n3 {\n margin: -1rem !important;\n }\n .mt-lg-n3,\n .my-lg-n3 {\n margin-top: -1rem !important;\n }\n .mr-lg-n3,\n .mx-lg-n3 {\n margin-right: -1rem !important;\n }\n .mb-lg-n3,\n .my-lg-n3 {\n margin-bottom: -1rem !important;\n }\n .ml-lg-n3,\n .mx-lg-n3 {\n margin-left: -1rem !important;\n }\n .m-lg-n4 {\n margin: -1.5rem !important;\n }\n .mt-lg-n4,\n .my-lg-n4 {\n margin-top: -1.5rem !important;\n }\n .mr-lg-n4,\n .mx-lg-n4 {\n margin-right: -1.5rem !important;\n }\n .mb-lg-n4,\n .my-lg-n4 {\n margin-bottom: -1.5rem !important;\n }\n .ml-lg-n4,\n .mx-lg-n4 {\n margin-left: -1.5rem !important;\n }\n .m-lg-n5 {\n margin: -3rem !important;\n }\n .mt-lg-n5,\n .my-lg-n5 {\n margin-top: -3rem !important;\n }\n .mr-lg-n5,\n .mx-lg-n5 {\n margin-right: -3rem !important;\n }\n .mb-lg-n5,\n .my-lg-n5 {\n margin-bottom: -3rem !important;\n }\n .ml-lg-n5,\n .mx-lg-n5 {\n margin-left: -3rem !important;\n }\n .m-lg-auto {\n margin: auto !important;\n }\n .mt-lg-auto,\n .my-lg-auto {\n margin-top: auto !important;\n }\n .mr-lg-auto,\n .mx-lg-auto {\n margin-right: auto !important;\n }\n .mb-lg-auto,\n .my-lg-auto {\n margin-bottom: auto !important;\n }\n .ml-lg-auto,\n .mx-lg-auto {\n margin-left: auto !important;\n }\n}\n\n@media (min-width: 1200px) {\n .m-xl-0 {\n margin: 0 !important;\n }\n .mt-xl-0,\n .my-xl-0 {\n margin-top: 0 !important;\n }\n .mr-xl-0,\n .mx-xl-0 {\n margin-right: 0 !important;\n }\n .mb-xl-0,\n .my-xl-0 {\n margin-bottom: 0 !important;\n }\n .ml-xl-0,\n .mx-xl-0 {\n margin-left: 0 !important;\n }\n .m-xl-1 {\n margin: 0.25rem !important;\n }\n .mt-xl-1,\n .my-xl-1 {\n margin-top: 0.25rem !important;\n }\n .mr-xl-1,\n .mx-xl-1 {\n margin-right: 0.25rem !important;\n }\n .mb-xl-1,\n .my-xl-1 {\n margin-bottom: 0.25rem !important;\n }\n .ml-xl-1,\n .mx-xl-1 {\n margin-left: 0.25rem !important;\n }\n .m-xl-2 {\n margin: 0.5rem !important;\n }\n .mt-xl-2,\n .my-xl-2 {\n margin-top: 0.5rem !important;\n }\n .mr-xl-2,\n .mx-xl-2 {\n margin-right: 0.5rem !important;\n }\n .mb-xl-2,\n .my-xl-2 {\n margin-bottom: 0.5rem !important;\n }\n .ml-xl-2,\n .mx-xl-2 {\n margin-left: 0.5rem !important;\n }\n .m-xl-3 {\n margin: 1rem !important;\n }\n .mt-xl-3,\n .my-xl-3 {\n margin-top: 1rem !important;\n }\n .mr-xl-3,\n .mx-xl-3 {\n margin-right: 1rem !important;\n }\n .mb-xl-3,\n .my-xl-3 {\n margin-bottom: 1rem !important;\n }\n .ml-xl-3,\n .mx-xl-3 {\n margin-left: 1rem !important;\n }\n .m-xl-4 {\n margin: 1.5rem !important;\n }\n .mt-xl-4,\n .my-xl-4 {\n margin-top: 1.5rem !important;\n }\n .mr-xl-4,\n .mx-xl-4 {\n margin-right: 1.5rem !important;\n }\n .mb-xl-4,\n .my-xl-4 {\n margin-bottom: 1.5rem !important;\n }\n .ml-xl-4,\n .mx-xl-4 {\n margin-left: 1.5rem !important;\n }\n .m-xl-5 {\n margin: 3rem !important;\n }\n .mt-xl-5,\n .my-xl-5 {\n margin-top: 3rem !important;\n }\n .mr-xl-5,\n .mx-xl-5 {\n margin-right: 3rem !important;\n }\n .mb-xl-5,\n .my-xl-5 {\n margin-bottom: 3rem !important;\n }\n .ml-xl-5,\n .mx-xl-5 {\n margin-left: 3rem !important;\n }\n .p-xl-0 {\n padding: 0 !important;\n }\n .pt-xl-0,\n .py-xl-0 {\n padding-top: 0 !important;\n }\n .pr-xl-0,\n .px-xl-0 {\n padding-right: 0 !important;\n }\n .pb-xl-0,\n .py-xl-0 {\n padding-bottom: 0 !important;\n }\n .pl-xl-0,\n .px-xl-0 {\n padding-left: 0 !important;\n }\n .p-xl-1 {\n padding: 0.25rem !important;\n }\n .pt-xl-1,\n .py-xl-1 {\n padding-top: 0.25rem !important;\n }\n .pr-xl-1,\n .px-xl-1 {\n padding-right: 0.25rem !important;\n }\n .pb-xl-1,\n .py-xl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pl-xl-1,\n .px-xl-1 {\n padding-left: 0.25rem !important;\n }\n .p-xl-2 {\n padding: 0.5rem !important;\n }\n .pt-xl-2,\n .py-xl-2 {\n padding-top: 0.5rem !important;\n }\n .pr-xl-2,\n .px-xl-2 {\n padding-right: 0.5rem !important;\n }\n .pb-xl-2,\n .py-xl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pl-xl-2,\n .px-xl-2 {\n padding-left: 0.5rem !important;\n }\n .p-xl-3 {\n padding: 1rem !important;\n }\n .pt-xl-3,\n .py-xl-3 {\n padding-top: 1rem !important;\n }\n .pr-xl-3,\n .px-xl-3 {\n padding-right: 1rem !important;\n }\n .pb-xl-3,\n .py-xl-3 {\n padding-bottom: 1rem !important;\n }\n .pl-xl-3,\n .px-xl-3 {\n padding-left: 1rem !important;\n }\n .p-xl-4 {\n padding: 1.5rem !important;\n }\n .pt-xl-4,\n .py-xl-4 {\n padding-top: 1.5rem !important;\n }\n .pr-xl-4,\n .px-xl-4 {\n padding-right: 1.5rem !important;\n }\n .pb-xl-4,\n .py-xl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pl-xl-4,\n .px-xl-4 {\n padding-left: 1.5rem !important;\n }\n .p-xl-5 {\n padding: 3rem !important;\n }\n .pt-xl-5,\n .py-xl-5 {\n padding-top: 3rem !important;\n }\n .pr-xl-5,\n .px-xl-5 {\n padding-right: 3rem !important;\n }\n .pb-xl-5,\n .py-xl-5 {\n padding-bottom: 3rem !important;\n }\n .pl-xl-5,\n .px-xl-5 {\n padding-left: 3rem !important;\n }\n .m-xl-n1 {\n margin: -0.25rem !important;\n }\n .mt-xl-n1,\n .my-xl-n1 {\n margin-top: -0.25rem !important;\n }\n .mr-xl-n1,\n .mx-xl-n1 {\n margin-right: -0.25rem !important;\n }\n .mb-xl-n1,\n .my-xl-n1 {\n margin-bottom: -0.25rem !important;\n }\n .ml-xl-n1,\n .mx-xl-n1 {\n margin-left: -0.25rem !important;\n }\n .m-xl-n2 {\n margin: -0.5rem !important;\n }\n .mt-xl-n2,\n .my-xl-n2 {\n margin-top: -0.5rem !important;\n }\n .mr-xl-n2,\n .mx-xl-n2 {\n margin-right: -0.5rem !important;\n }\n .mb-xl-n2,\n .my-xl-n2 {\n margin-bottom: -0.5rem !important;\n }\n .ml-xl-n2,\n .mx-xl-n2 {\n margin-left: -0.5rem !important;\n }\n .m-xl-n3 {\n margin: -1rem !important;\n }\n .mt-xl-n3,\n .my-xl-n3 {\n margin-top: -1rem !important;\n }\n .mr-xl-n3,\n .mx-xl-n3 {\n margin-right: -1rem !important;\n }\n .mb-xl-n3,\n .my-xl-n3 {\n margin-bottom: -1rem !important;\n }\n .ml-xl-n3,\n .mx-xl-n3 {\n margin-left: -1rem !important;\n }\n .m-xl-n4 {\n margin: -1.5rem !important;\n }\n .mt-xl-n4,\n .my-xl-n4 {\n margin-top: -1.5rem !important;\n }\n .mr-xl-n4,\n .mx-xl-n4 {\n margin-right: -1.5rem !important;\n }\n .mb-xl-n4,\n .my-xl-n4 {\n margin-bottom: -1.5rem !important;\n }\n .ml-xl-n4,\n .mx-xl-n4 {\n margin-left: -1.5rem !important;\n }\n .m-xl-n5 {\n margin: -3rem !important;\n }\n .mt-xl-n5,\n .my-xl-n5 {\n margin-top: -3rem !important;\n }\n .mr-xl-n5,\n .mx-xl-n5 {\n margin-right: -3rem !important;\n }\n .mb-xl-n5,\n .my-xl-n5 {\n margin-bottom: -3rem !important;\n }\n .ml-xl-n5,\n .mx-xl-n5 {\n margin-left: -3rem !important;\n }\n .m-xl-auto {\n margin: auto !important;\n }\n .mt-xl-auto,\n .my-xl-auto {\n margin-top: auto !important;\n }\n .mr-xl-auto,\n .mx-xl-auto {\n margin-right: auto !important;\n }\n .mb-xl-auto,\n .my-xl-auto {\n margin-bottom: auto !important;\n }\n .ml-xl-auto,\n .mx-xl-auto {\n margin-left: auto !important;\n }\n}\n\n.text-monospace {\n font-family: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace !important;\n}\n\n.text-justify {\n text-align: justify !important;\n}\n\n.text-wrap {\n white-space: normal !important;\n}\n\n.text-nowrap {\n white-space: nowrap !important;\n}\n\n.text-truncate {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.text-left {\n text-align: left !important;\n}\n\n.text-right {\n text-align: right !important;\n}\n\n.text-center {\n text-align: center !important;\n}\n\n@media (min-width: 576px) {\n .text-sm-left {\n text-align: left !important;\n }\n .text-sm-right {\n text-align: right !important;\n }\n .text-sm-center {\n text-align: center !important;\n }\n}\n\n@media (min-width: 768px) {\n .text-md-left {\n text-align: left !important;\n }\n .text-md-right {\n text-align: right !important;\n }\n .text-md-center {\n text-align: center !important;\n }\n}\n\n@media (min-width: 992px) {\n .text-lg-left {\n text-align: left !important;\n }\n .text-lg-right {\n text-align: right !important;\n }\n .text-lg-center {\n text-align: center !important;\n }\n}\n\n@media (min-width: 1200px) {\n .text-xl-left {\n text-align: left !important;\n }\n .text-xl-right {\n text-align: right !important;\n }\n .text-xl-center {\n text-align: center !important;\n }\n}\n\n.text-lowercase {\n text-transform: lowercase !important;\n}\n\n.text-uppercase {\n text-transform: uppercase !important;\n}\n\n.text-capitalize {\n text-transform: capitalize !important;\n}\n\n.font-weight-light {\n font-weight: 300 !important;\n}\n\n.font-weight-lighter {\n font-weight: lighter !important;\n}\n\n.font-weight-normal {\n font-weight: 400 !important;\n}\n\n.font-weight-bold {\n font-weight: 700 !important;\n}\n\n.font-weight-bolder {\n font-weight: bolder !important;\n}\n\n.font-italic {\n font-style: italic !important;\n}\n\n.text-white {\n color: #fff !important;\n}\n\n.text-primary {\n color: #007bff !important;\n}\n\na.text-primary:hover, a.text-primary:focus {\n color: #0056b3 !important;\n}\n\n.text-secondary {\n color: #6c757d !important;\n}\n\na.text-secondary:hover, a.text-secondary:focus {\n color: #494f54 !important;\n}\n\n.text-success {\n color: #28a745 !important;\n}\n\na.text-success:hover, a.text-success:focus {\n color: #19692c !important;\n}\n\n.text-info {\n color: #17a2b8 !important;\n}\n\na.text-info:hover, a.text-info:focus {\n color: #0f6674 !important;\n}\n\n.text-warning {\n color: #ffc107 !important;\n}\n\na.text-warning:hover, a.text-warning:focus {\n color: #ba8b00 !important;\n}\n\n.text-danger {\n color: #dc3545 !important;\n}\n\na.text-danger:hover, a.text-danger:focus {\n color: #a71d2a !important;\n}\n\n.text-light {\n color: #f8f9fa !important;\n}\n\na.text-light:hover, a.text-light:focus {\n color: #cbd3da !important;\n}\n\n.text-dark {\n color: #343a40 !important;\n}\n\na.text-dark:hover, a.text-dark:focus {\n color: #121416 !important;\n}\n\n.text-body {\n color: #212529 !important;\n}\n\n.text-muted {\n color: #6c757d !important;\n}\n\n.text-black-50 {\n color: rgba(0, 0, 0, 0.5) !important;\n}\n\n.text-white-50 {\n color: rgba(255, 255, 255, 0.5) !important;\n}\n\n.text-hide {\n font: 0/0 a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n\n.text-decoration-none {\n text-decoration: none !important;\n}\n\n.text-break {\n word-break: break-word !important;\n overflow-wrap: break-word !important;\n}\n\n.text-reset {\n color: inherit !important;\n}\n\n.visible {\n visibility: visible !important;\n}\n\n.invisible {\n visibility: hidden !important;\n}\n\n@media print {\n *,\n *::before,\n *::after {\n text-shadow: none !important;\n box-shadow: none !important;\n }\n a:not(.btn) {\n text-decoration: underline;\n }\n abbr[title]::after {\n content: \" (\" attr(title) \")\";\n }\n pre {\n white-space: pre-wrap !important;\n }\n pre,\n blockquote {\n border: 1px solid #adb5bd;\n page-break-inside: avoid;\n }\n thead {\n display: table-header-group;\n }\n tr,\n img {\n page-break-inside: avoid;\n }\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n h2,\n h3 {\n page-break-after: avoid;\n }\n @page {\n size: a3;\n }\n body {\n min-width: 992px !important;\n }\n .container {\n min-width: 992px !important;\n }\n .navbar {\n display: none;\n }\n .badge {\n border: 1px solid #000;\n }\n .table {\n border-collapse: collapse !important;\n }\n .table td,\n .table th {\n background-color: #fff !important;\n }\n .table-bordered th,\n .table-bordered td {\n border: 1px solid #dee2e6 !important;\n }\n .table-dark {\n color: inherit;\n }\n .table-dark th,\n .table-dark td,\n .table-dark thead th,\n .table-dark tbody + tbody {\n border-color: #dee2e6;\n }\n .table .thead-dark th {\n color: inherit;\n border-color: #dee2e6;\n }\n}\n/*# sourceMappingURL=bootstrap.css.map */","// stylelint-disable property-blacklist, scss/dollar-variable-default\n\n// SCSS RFS mixin\n//\n// Automated font-resizing\n//\n// See https://github.com/twbs/rfs\n\n// Configuration\n\n// Base font size\n$rfs-base-font-size: 1.25rem !default;\n$rfs-font-size-unit: rem !default;\n\n// Breakpoint at where font-size starts decreasing if screen width is smaller\n$rfs-breakpoint: 1200px !default;\n$rfs-breakpoint-unit: px !default;\n\n// Resize font-size based on screen height and width\n$rfs-two-dimensional: false !default;\n\n// Factor of decrease\n$rfs-factor: 10 !default;\n\n@if type-of($rfs-factor) != \"number\" or $rfs-factor <= 1 {\n @error \"`#{$rfs-factor}` is not a valid $rfs-factor, it must be greater than 1.\";\n}\n\n// Generate enable or disable classes. Possibilities: false, \"enable\" or \"disable\"\n$rfs-class: false !default;\n\n// 1 rem = $rfs-rem-value px\n$rfs-rem-value: 16 !default;\n\n// Safari iframe resize bug: https://github.com/twbs/rfs/issues/14\n$rfs-safari-iframe-resize-bug-fix: false !default;\n\n// Disable RFS by setting $enable-responsive-font-sizes to false\n$enable-responsive-font-sizes: true !default;\n\n// Cache $rfs-base-font-size unit\n$rfs-base-font-size-unit: unit($rfs-base-font-size);\n\n// Remove px-unit from $rfs-base-font-size for calculations\n@if $rfs-base-font-size-unit == \"px\" {\n $rfs-base-font-size: $rfs-base-font-size / ($rfs-base-font-size * 0 + 1);\n}\n@else if $rfs-base-font-size-unit == \"rem\" {\n $rfs-base-font-size: $rfs-base-font-size / ($rfs-base-font-size * 0 + 1 / $rfs-rem-value);\n}\n\n// Cache $rfs-breakpoint unit to prevent multiple calls\n$rfs-breakpoint-unit-cache: unit($rfs-breakpoint);\n\n// Remove unit from $rfs-breakpoint for calculations\n@if $rfs-breakpoint-unit-cache == \"px\" {\n $rfs-breakpoint: $rfs-breakpoint / ($rfs-breakpoint * 0 + 1);\n}\n@else if $rfs-breakpoint-unit-cache == \"rem\" or $rfs-breakpoint-unit-cache == \"em\" {\n $rfs-breakpoint: $rfs-breakpoint / ($rfs-breakpoint * 0 + 1 / $rfs-rem-value);\n}\n\n// Responsive font-size mixin\n@mixin rfs($fs, $important: false) {\n // Cache $fs unit\n $fs-unit: if(type-of($fs) == \"number\", unit($fs), false);\n\n // Add !important suffix if needed\n $rfs-suffix: if($important, \" !important\", \"\");\n\n // If $fs isn't a number (like inherit) or $fs has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n @if not $fs-unit or $fs-unit != \"\" and $fs-unit != \"px\" and $fs-unit != \"rem\" or $fs == 0 {\n font-size: #{$fs}#{$rfs-suffix};\n }\n @else {\n // Variables for storing static and fluid rescaling\n $rfs-static: null;\n $rfs-fluid: null;\n\n // Remove px-unit from $fs for calculations\n @if $fs-unit == \"px\" {\n $fs: $fs / ($fs * 0 + 1);\n }\n @else if $fs-unit == \"rem\" {\n $fs: $fs / ($fs * 0 + 1 / $rfs-rem-value);\n }\n\n // Set default font-size\n @if $rfs-font-size-unit == rem {\n $rfs-static: #{$fs / $rfs-rem-value}rem#{$rfs-suffix};\n }\n @else if $rfs-font-size-unit == px {\n $rfs-static: #{$fs}px#{$rfs-suffix};\n }\n @else {\n @error \"`#{$rfs-font-size-unit}` is not a valid unit for $rfs-font-size-unit. Use `px` or `rem`.\";\n }\n\n // Only add media query if font-size is bigger as the minimum font-size\n // If $rfs-factor == 1, no rescaling will take place\n @if $fs > $rfs-base-font-size and $enable-responsive-font-sizes {\n $min-width: null;\n $variable-unit: null;\n\n // Calculate minimum font-size for given font-size\n $fs-min: $rfs-base-font-size + ($fs - $rfs-base-font-size) / $rfs-factor;\n\n // Calculate difference between given font-size and minimum font-size for given font-size\n $fs-diff: $fs - $fs-min;\n\n // Base font-size formatting\n // No need to check if the unit is valid, because we did that before\n $min-width: if($rfs-font-size-unit == rem, #{$fs-min / $rfs-rem-value}rem, #{$fs-min}px);\n\n // If two-dimensional, use smallest of screen width and height\n $variable-unit: if($rfs-two-dimensional, vmin, vw);\n\n // Calculate the variable width between 0 and $rfs-breakpoint\n $variable-width: #{$fs-diff * 100 / $rfs-breakpoint}#{$variable-unit};\n\n // Set the calculated font-size.\n $rfs-fluid: calc(#{$min-width} + #{$variable-width}) #{$rfs-suffix};\n }\n\n // Rendering\n @if $rfs-fluid == null {\n // Only render static font-size if no fluid font-size is available\n font-size: $rfs-static;\n }\n @else {\n $mq-value: null;\n\n // RFS breakpoint formatting\n @if $rfs-breakpoint-unit == em or $rfs-breakpoint-unit == rem {\n $mq-value: #{$rfs-breakpoint / $rfs-rem-value}#{$rfs-breakpoint-unit};\n }\n @else if $rfs-breakpoint-unit == px {\n $mq-value: #{$rfs-breakpoint}px;\n }\n @else {\n @error \"`#{$rfs-breakpoint-unit}` is not a valid unit for $rfs-breakpoint-unit. Use `px`, `em` or `rem`.\";\n }\n\n @if $rfs-class == \"disable\" {\n // Adding an extra class increases specificity,\n // which prevents the media query to override the font size\n &,\n .disable-responsive-font-size &,\n &.disable-responsive-font-size {\n font-size: $rfs-static;\n }\n }\n @else {\n font-size: $rfs-static;\n }\n\n @if $rfs-two-dimensional {\n @media (max-width: #{$mq-value}), (max-height: #{$mq-value}) {\n @if $rfs-class == \"enable\" {\n .enable-responsive-font-size &,\n &.enable-responsive-font-size {\n font-size: $rfs-fluid;\n }\n }\n @else {\n font-size: $rfs-fluid;\n }\n\n @if $rfs-safari-iframe-resize-bug-fix {\n // stylelint-disable-next-line length-zero-no-unit\n min-width: 0vw;\n }\n }\n }\n @else {\n @media (max-width: #{$mq-value}) {\n @if $rfs-class == \"enable\" {\n .enable-responsive-font-size &,\n &.enable-responsive-font-size {\n font-size: $rfs-fluid;\n }\n }\n @else {\n font-size: $rfs-fluid;\n }\n\n @if $rfs-safari-iframe-resize-bug-fix {\n // stylelint-disable-next-line length-zero-no-unit\n min-width: 0vw;\n }\n }\n }\n }\n }\n}\n\n// The font-size & responsive-font-size mixin uses RFS to rescale font sizes\n@mixin font-size($fs, $important: false) {\n @include rfs($fs, $important);\n}\n\n@mixin responsive-font-size($fs, $important: false) {\n @include rfs($fs, $important);\n}\n","/*!\n * Bootstrap v4.3.1 (https://getbootstrap.com/)\n * Copyright 2011-2019 The Bootstrap Authors\n * Copyright 2011-2019 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n:root {\n --blue: #007bff;\n --indigo: #6610f2;\n --purple: #6f42c1;\n --pink: #e83e8c;\n --red: #dc3545;\n --orange: #fd7e14;\n --yellow: #ffc107;\n --green: #28a745;\n --teal: #20c997;\n --cyan: #17a2b8;\n --white: #fff;\n --gray: #6c757d;\n --gray-dark: #343a40;\n --primary: #007bff;\n --secondary: #6c757d;\n --success: #28a745;\n --info: #17a2b8;\n --warning: #ffc107;\n --danger: #dc3545;\n --light: #f8f9fa;\n --dark: #343a40;\n --breakpoint-xs: 0;\n --breakpoint-sm: 576px;\n --breakpoint-md: 768px;\n --breakpoint-lg: 992px;\n --breakpoint-xl: 1200px;\n --font-family-sans-serif: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n --font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n}\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-family: sans-serif;\n line-height: 1.15;\n -webkit-text-size-adjust: 100%;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\narticle, aside, figcaption, figure, footer, header, hgroup, main, nav, section {\n display: block;\n}\n\nbody {\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #212529;\n text-align: left;\n background-color: #fff;\n}\n\n[tabindex=\"-1\"]:focus {\n outline: 0 !important;\n}\n\nhr {\n box-sizing: content-box;\n height: 0;\n overflow: visible;\n}\n\nh1, h2, h3, h4, h5, h6 {\n margin-top: 0;\n margin-bottom: 0.5rem;\n}\n\np {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nabbr[title],\nabbr[data-original-title] {\n text-decoration: underline;\n text-decoration: underline dotted;\n cursor: help;\n border-bottom: 0;\n text-decoration-skip-ink: none;\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: 700;\n}\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0;\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: bolder;\n}\n\nsmall {\n font-size: 80%;\n}\n\nsub,\nsup {\n position: relative;\n font-size: 75%;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -.25em;\n}\n\nsup {\n top: -.5em;\n}\n\na {\n color: #007bff;\n text-decoration: none;\n background-color: transparent;\n}\n\na:hover {\n color: #0056b3;\n text-decoration: underline;\n}\n\na:not([href]):not([tabindex]) {\n color: inherit;\n text-decoration: none;\n}\n\na:not([href]):not([tabindex]):hover, a:not([href]):not([tabindex]):focus {\n color: inherit;\n text-decoration: none;\n}\n\na:not([href]):not([tabindex]):focus {\n outline: 0;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n font-size: 1em;\n}\n\npre {\n margin-top: 0;\n margin-bottom: 1rem;\n overflow: auto;\n}\n\nfigure {\n margin: 0 0 1rem;\n}\n\nimg {\n vertical-align: middle;\n border-style: none;\n}\n\nsvg {\n overflow: hidden;\n vertical-align: middle;\n}\n\ntable {\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: 0.75rem;\n padding-bottom: 0.75rem;\n color: #6c757d;\n text-align: left;\n caption-side: bottom;\n}\n\nth {\n text-align: inherit;\n}\n\nlabel {\n display: inline-block;\n margin-bottom: 0.5rem;\n}\n\nbutton {\n border-radius: 0;\n}\n\nbutton:focus {\n outline: 1px dotted;\n outline: 5px auto -webkit-focus-ring-color;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\nbutton,\ninput {\n overflow: visible;\n}\n\nbutton,\nselect {\n text-transform: none;\n}\n\nselect {\n word-wrap: normal;\n}\n\nbutton,\n[type=\"button\"],\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button;\n}\n\nbutton:not(:disabled),\n[type=\"button\"]:not(:disabled),\n[type=\"reset\"]:not(:disabled),\n[type=\"submit\"]:not(:disabled) {\n cursor: pointer;\n}\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n box-sizing: border-box;\n padding: 0;\n}\n\ninput[type=\"date\"],\ninput[type=\"time\"],\ninput[type=\"datetime-local\"],\ninput[type=\"month\"] {\n -webkit-appearance: listbox;\n}\n\ntextarea {\n overflow: auto;\n resize: vertical;\n}\n\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\n\nlegend {\n display: block;\n width: 100%;\n max-width: 100%;\n padding: 0;\n margin-bottom: .5rem;\n font-size: 1.5rem;\n line-height: inherit;\n color: inherit;\n white-space: normal;\n}\n\nprogress {\n vertical-align: baseline;\n}\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n[type=\"search\"] {\n outline-offset: -2px;\n -webkit-appearance: none;\n}\n\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n::-webkit-file-upload-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\noutput {\n display: inline-block;\n}\n\nsummary {\n display: list-item;\n cursor: pointer;\n}\n\ntemplate {\n display: none;\n}\n\n[hidden] {\n display: none !important;\n}\n\nh1, h2, h3, h4, h5, h6,\n.h1, .h2, .h3, .h4, .h5, .h6 {\n margin-bottom: 0.5rem;\n font-weight: 500;\n line-height: 1.2;\n}\n\nh1, .h1 {\n font-size: 2.5rem;\n}\n\nh2, .h2 {\n font-size: 2rem;\n}\n\nh3, .h3 {\n font-size: 1.75rem;\n}\n\nh4, .h4 {\n font-size: 1.5rem;\n}\n\nh5, .h5 {\n font-size: 1.25rem;\n}\n\nh6, .h6 {\n font-size: 1rem;\n}\n\n.lead {\n font-size: 1.25rem;\n font-weight: 300;\n}\n\n.display-1 {\n font-size: 6rem;\n font-weight: 300;\n line-height: 1.2;\n}\n\n.display-2 {\n font-size: 5.5rem;\n font-weight: 300;\n line-height: 1.2;\n}\n\n.display-3 {\n font-size: 4.5rem;\n font-weight: 300;\n line-height: 1.2;\n}\n\n.display-4 {\n font-size: 3.5rem;\n font-weight: 300;\n line-height: 1.2;\n}\n\nhr {\n margin-top: 1rem;\n margin-bottom: 1rem;\n border: 0;\n border-top: 1px solid rgba(0, 0, 0, 0.1);\n}\n\nsmall,\n.small {\n font-size: 80%;\n font-weight: 400;\n}\n\nmark,\n.mark {\n padding: 0.2em;\n background-color: #fcf8e3;\n}\n\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n\n.list-inline {\n padding-left: 0;\n list-style: none;\n}\n\n.list-inline-item {\n display: inline-block;\n}\n\n.list-inline-item:not(:last-child) {\n margin-right: 0.5rem;\n}\n\n.initialism {\n font-size: 90%;\n text-transform: uppercase;\n}\n\n.blockquote {\n margin-bottom: 1rem;\n font-size: 1.25rem;\n}\n\n.blockquote-footer {\n display: block;\n font-size: 80%;\n color: #6c757d;\n}\n\n.blockquote-footer::before {\n content: \"\\2014\\00A0\";\n}\n\n.img-fluid {\n max-width: 100%;\n height: auto;\n}\n\n.img-thumbnail {\n padding: 0.25rem;\n background-color: #fff;\n border: 1px solid #dee2e6;\n border-radius: 0.25rem;\n max-width: 100%;\n height: auto;\n}\n\n.figure {\n display: inline-block;\n}\n\n.figure-img {\n margin-bottom: 0.5rem;\n line-height: 1;\n}\n\n.figure-caption {\n font-size: 90%;\n color: #6c757d;\n}\n\ncode {\n font-size: 87.5%;\n color: #e83e8c;\n word-break: break-word;\n}\n\na > code {\n color: inherit;\n}\n\nkbd {\n padding: 0.2rem 0.4rem;\n font-size: 87.5%;\n color: #fff;\n background-color: #212529;\n border-radius: 0.2rem;\n}\n\nkbd kbd {\n padding: 0;\n font-size: 100%;\n font-weight: 700;\n}\n\npre {\n display: block;\n font-size: 87.5%;\n color: #212529;\n}\n\npre code {\n font-size: inherit;\n color: inherit;\n word-break: normal;\n}\n\n.pre-scrollable {\n max-height: 340px;\n overflow-y: scroll;\n}\n\n.container {\n width: 100%;\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n\n@media (min-width: 576px) {\n .container {\n max-width: 540px;\n }\n}\n\n@media (min-width: 768px) {\n .container {\n max-width: 720px;\n }\n}\n\n@media (min-width: 992px) {\n .container {\n max-width: 960px;\n }\n}\n\n@media (min-width: 1200px) {\n .container {\n max-width: 1140px;\n }\n}\n\n.container-fluid {\n width: 100%;\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n\n.row {\n display: flex;\n flex-wrap: wrap;\n margin-right: -15px;\n margin-left: -15px;\n}\n\n.no-gutters {\n margin-right: 0;\n margin-left: 0;\n}\n\n.no-gutters > .col,\n.no-gutters > [class*=\"col-\"] {\n padding-right: 0;\n padding-left: 0;\n}\n\n.col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12, .col,\n.col-auto, .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm,\n.col-sm-auto, .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12, .col-md,\n.col-md-auto, .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg,\n.col-lg-auto, .col-xl-1, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl,\n.col-xl-auto {\n position: relative;\n width: 100%;\n padding-right: 15px;\n padding-left: 15px;\n}\n\n.col {\n flex-basis: 0;\n flex-grow: 1;\n max-width: 100%;\n}\n\n.col-auto {\n flex: 0 0 auto;\n width: auto;\n max-width: 100%;\n}\n\n.col-1 {\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n}\n\n.col-2 {\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n}\n\n.col-3 {\n flex: 0 0 25%;\n max-width: 25%;\n}\n\n.col-4 {\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n}\n\n.col-5 {\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n}\n\n.col-6 {\n flex: 0 0 50%;\n max-width: 50%;\n}\n\n.col-7 {\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n}\n\n.col-8 {\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n}\n\n.col-9 {\n flex: 0 0 75%;\n max-width: 75%;\n}\n\n.col-10 {\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n}\n\n.col-11 {\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n}\n\n.col-12 {\n flex: 0 0 100%;\n max-width: 100%;\n}\n\n.order-first {\n order: -1;\n}\n\n.order-last {\n order: 13;\n}\n\n.order-0 {\n order: 0;\n}\n\n.order-1 {\n order: 1;\n}\n\n.order-2 {\n order: 2;\n}\n\n.order-3 {\n order: 3;\n}\n\n.order-4 {\n order: 4;\n}\n\n.order-5 {\n order: 5;\n}\n\n.order-6 {\n order: 6;\n}\n\n.order-7 {\n order: 7;\n}\n\n.order-8 {\n order: 8;\n}\n\n.order-9 {\n order: 9;\n}\n\n.order-10 {\n order: 10;\n}\n\n.order-11 {\n order: 11;\n}\n\n.order-12 {\n order: 12;\n}\n\n.offset-1 {\n margin-left: 8.333333%;\n}\n\n.offset-2 {\n margin-left: 16.666667%;\n}\n\n.offset-3 {\n margin-left: 25%;\n}\n\n.offset-4 {\n margin-left: 33.333333%;\n}\n\n.offset-5 {\n margin-left: 41.666667%;\n}\n\n.offset-6 {\n margin-left: 50%;\n}\n\n.offset-7 {\n margin-left: 58.333333%;\n}\n\n.offset-8 {\n margin-left: 66.666667%;\n}\n\n.offset-9 {\n margin-left: 75%;\n}\n\n.offset-10 {\n margin-left: 83.333333%;\n}\n\n.offset-11 {\n margin-left: 91.666667%;\n}\n\n@media (min-width: 576px) {\n .col-sm {\n flex-basis: 0;\n flex-grow: 1;\n max-width: 100%;\n }\n .col-sm-auto {\n flex: 0 0 auto;\n width: auto;\n max-width: 100%;\n }\n .col-sm-1 {\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n }\n .col-sm-2 {\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n }\n .col-sm-3 {\n flex: 0 0 25%;\n max-width: 25%;\n }\n .col-sm-4 {\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n }\n .col-sm-5 {\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n }\n .col-sm-6 {\n flex: 0 0 50%;\n max-width: 50%;\n }\n .col-sm-7 {\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n }\n .col-sm-8 {\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n }\n .col-sm-9 {\n flex: 0 0 75%;\n max-width: 75%;\n }\n .col-sm-10 {\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n }\n .col-sm-11 {\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n }\n .col-sm-12 {\n flex: 0 0 100%;\n max-width: 100%;\n }\n .order-sm-first {\n order: -1;\n }\n .order-sm-last {\n order: 13;\n }\n .order-sm-0 {\n order: 0;\n }\n .order-sm-1 {\n order: 1;\n }\n .order-sm-2 {\n order: 2;\n }\n .order-sm-3 {\n order: 3;\n }\n .order-sm-4 {\n order: 4;\n }\n .order-sm-5 {\n order: 5;\n }\n .order-sm-6 {\n order: 6;\n }\n .order-sm-7 {\n order: 7;\n }\n .order-sm-8 {\n order: 8;\n }\n .order-sm-9 {\n order: 9;\n }\n .order-sm-10 {\n order: 10;\n }\n .order-sm-11 {\n order: 11;\n }\n .order-sm-12 {\n order: 12;\n }\n .offset-sm-0 {\n margin-left: 0;\n }\n .offset-sm-1 {\n margin-left: 8.333333%;\n }\n .offset-sm-2 {\n margin-left: 16.666667%;\n }\n .offset-sm-3 {\n margin-left: 25%;\n }\n .offset-sm-4 {\n margin-left: 33.333333%;\n }\n .offset-sm-5 {\n margin-left: 41.666667%;\n }\n .offset-sm-6 {\n margin-left: 50%;\n }\n .offset-sm-7 {\n margin-left: 58.333333%;\n }\n .offset-sm-8 {\n margin-left: 66.666667%;\n }\n .offset-sm-9 {\n margin-left: 75%;\n }\n .offset-sm-10 {\n margin-left: 83.333333%;\n }\n .offset-sm-11 {\n margin-left: 91.666667%;\n }\n}\n\n@media (min-width: 768px) {\n .col-md {\n flex-basis: 0;\n flex-grow: 1;\n max-width: 100%;\n }\n .col-md-auto {\n flex: 0 0 auto;\n width: auto;\n max-width: 100%;\n }\n .col-md-1 {\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n }\n .col-md-2 {\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n }\n .col-md-3 {\n flex: 0 0 25%;\n max-width: 25%;\n }\n .col-md-4 {\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n }\n .col-md-5 {\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n }\n .col-md-6 {\n flex: 0 0 50%;\n max-width: 50%;\n }\n .col-md-7 {\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n }\n .col-md-8 {\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n }\n .col-md-9 {\n flex: 0 0 75%;\n max-width: 75%;\n }\n .col-md-10 {\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n }\n .col-md-11 {\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n }\n .col-md-12 {\n flex: 0 0 100%;\n max-width: 100%;\n }\n .order-md-first {\n order: -1;\n }\n .order-md-last {\n order: 13;\n }\n .order-md-0 {\n order: 0;\n }\n .order-md-1 {\n order: 1;\n }\n .order-md-2 {\n order: 2;\n }\n .order-md-3 {\n order: 3;\n }\n .order-md-4 {\n order: 4;\n }\n .order-md-5 {\n order: 5;\n }\n .order-md-6 {\n order: 6;\n }\n .order-md-7 {\n order: 7;\n }\n .order-md-8 {\n order: 8;\n }\n .order-md-9 {\n order: 9;\n }\n .order-md-10 {\n order: 10;\n }\n .order-md-11 {\n order: 11;\n }\n .order-md-12 {\n order: 12;\n }\n .offset-md-0 {\n margin-left: 0;\n }\n .offset-md-1 {\n margin-left: 8.333333%;\n }\n .offset-md-2 {\n margin-left: 16.666667%;\n }\n .offset-md-3 {\n margin-left: 25%;\n }\n .offset-md-4 {\n margin-left: 33.333333%;\n }\n .offset-md-5 {\n margin-left: 41.666667%;\n }\n .offset-md-6 {\n margin-left: 50%;\n }\n .offset-md-7 {\n margin-left: 58.333333%;\n }\n .offset-md-8 {\n margin-left: 66.666667%;\n }\n .offset-md-9 {\n margin-left: 75%;\n }\n .offset-md-10 {\n margin-left: 83.333333%;\n }\n .offset-md-11 {\n margin-left: 91.666667%;\n }\n}\n\n@media (min-width: 992px) {\n .col-lg {\n flex-basis: 0;\n flex-grow: 1;\n max-width: 100%;\n }\n .col-lg-auto {\n flex: 0 0 auto;\n width: auto;\n max-width: 100%;\n }\n .col-lg-1 {\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n }\n .col-lg-2 {\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n }\n .col-lg-3 {\n flex: 0 0 25%;\n max-width: 25%;\n }\n .col-lg-4 {\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n }\n .col-lg-5 {\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n }\n .col-lg-6 {\n flex: 0 0 50%;\n max-width: 50%;\n }\n .col-lg-7 {\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n }\n .col-lg-8 {\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n }\n .col-lg-9 {\n flex: 0 0 75%;\n max-width: 75%;\n }\n .col-lg-10 {\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n }\n .col-lg-11 {\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n }\n .col-lg-12 {\n flex: 0 0 100%;\n max-width: 100%;\n }\n .order-lg-first {\n order: -1;\n }\n .order-lg-last {\n order: 13;\n }\n .order-lg-0 {\n order: 0;\n }\n .order-lg-1 {\n order: 1;\n }\n .order-lg-2 {\n order: 2;\n }\n .order-lg-3 {\n order: 3;\n }\n .order-lg-4 {\n order: 4;\n }\n .order-lg-5 {\n order: 5;\n }\n .order-lg-6 {\n order: 6;\n }\n .order-lg-7 {\n order: 7;\n }\n .order-lg-8 {\n order: 8;\n }\n .order-lg-9 {\n order: 9;\n }\n .order-lg-10 {\n order: 10;\n }\n .order-lg-11 {\n order: 11;\n }\n .order-lg-12 {\n order: 12;\n }\n .offset-lg-0 {\n margin-left: 0;\n }\n .offset-lg-1 {\n margin-left: 8.333333%;\n }\n .offset-lg-2 {\n margin-left: 16.666667%;\n }\n .offset-lg-3 {\n margin-left: 25%;\n }\n .offset-lg-4 {\n margin-left: 33.333333%;\n }\n .offset-lg-5 {\n margin-left: 41.666667%;\n }\n .offset-lg-6 {\n margin-left: 50%;\n }\n .offset-lg-7 {\n margin-left: 58.333333%;\n }\n .offset-lg-8 {\n margin-left: 66.666667%;\n }\n .offset-lg-9 {\n margin-left: 75%;\n }\n .offset-lg-10 {\n margin-left: 83.333333%;\n }\n .offset-lg-11 {\n margin-left: 91.666667%;\n }\n}\n\n@media (min-width: 1200px) {\n .col-xl {\n flex-basis: 0;\n flex-grow: 1;\n max-width: 100%;\n }\n .col-xl-auto {\n flex: 0 0 auto;\n width: auto;\n max-width: 100%;\n }\n .col-xl-1 {\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n }\n .col-xl-2 {\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n }\n .col-xl-3 {\n flex: 0 0 25%;\n max-width: 25%;\n }\n .col-xl-4 {\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n }\n .col-xl-5 {\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n }\n .col-xl-6 {\n flex: 0 0 50%;\n max-width: 50%;\n }\n .col-xl-7 {\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n }\n .col-xl-8 {\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n }\n .col-xl-9 {\n flex: 0 0 75%;\n max-width: 75%;\n }\n .col-xl-10 {\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n }\n .col-xl-11 {\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n }\n .col-xl-12 {\n flex: 0 0 100%;\n max-width: 100%;\n }\n .order-xl-first {\n order: -1;\n }\n .order-xl-last {\n order: 13;\n }\n .order-xl-0 {\n order: 0;\n }\n .order-xl-1 {\n order: 1;\n }\n .order-xl-2 {\n order: 2;\n }\n .order-xl-3 {\n order: 3;\n }\n .order-xl-4 {\n order: 4;\n }\n .order-xl-5 {\n order: 5;\n }\n .order-xl-6 {\n order: 6;\n }\n .order-xl-7 {\n order: 7;\n }\n .order-xl-8 {\n order: 8;\n }\n .order-xl-9 {\n order: 9;\n }\n .order-xl-10 {\n order: 10;\n }\n .order-xl-11 {\n order: 11;\n }\n .order-xl-12 {\n order: 12;\n }\n .offset-xl-0 {\n margin-left: 0;\n }\n .offset-xl-1 {\n margin-left: 8.333333%;\n }\n .offset-xl-2 {\n margin-left: 16.666667%;\n }\n .offset-xl-3 {\n margin-left: 25%;\n }\n .offset-xl-4 {\n margin-left: 33.333333%;\n }\n .offset-xl-5 {\n margin-left: 41.666667%;\n }\n .offset-xl-6 {\n margin-left: 50%;\n }\n .offset-xl-7 {\n margin-left: 58.333333%;\n }\n .offset-xl-8 {\n margin-left: 66.666667%;\n }\n .offset-xl-9 {\n margin-left: 75%;\n }\n .offset-xl-10 {\n margin-left: 83.333333%;\n }\n .offset-xl-11 {\n margin-left: 91.666667%;\n }\n}\n\n.table {\n width: 100%;\n margin-bottom: 1rem;\n color: #212529;\n}\n\n.table th,\n.table td {\n padding: 0.75rem;\n vertical-align: top;\n border-top: 1px solid #dee2e6;\n}\n\n.table thead th {\n vertical-align: bottom;\n border-bottom: 2px solid #dee2e6;\n}\n\n.table tbody + tbody {\n border-top: 2px solid #dee2e6;\n}\n\n.table-sm th,\n.table-sm td {\n padding: 0.3rem;\n}\n\n.table-bordered {\n border: 1px solid #dee2e6;\n}\n\n.table-bordered th,\n.table-bordered td {\n border: 1px solid #dee2e6;\n}\n\n.table-bordered thead th,\n.table-bordered thead td {\n border-bottom-width: 2px;\n}\n\n.table-borderless th,\n.table-borderless td,\n.table-borderless thead th,\n.table-borderless tbody + tbody {\n border: 0;\n}\n\n.table-striped tbody tr:nth-of-type(odd) {\n background-color: rgba(0, 0, 0, 0.05);\n}\n\n.table-hover tbody tr:hover {\n color: #212529;\n background-color: rgba(0, 0, 0, 0.075);\n}\n\n.table-primary,\n.table-primary > th,\n.table-primary > td {\n background-color: #b8daff;\n}\n\n.table-primary th,\n.table-primary td,\n.table-primary thead th,\n.table-primary tbody + tbody {\n border-color: #7abaff;\n}\n\n.table-hover .table-primary:hover {\n background-color: #9fcdff;\n}\n\n.table-hover .table-primary:hover > td,\n.table-hover .table-primary:hover > th {\n background-color: #9fcdff;\n}\n\n.table-secondary,\n.table-secondary > th,\n.table-secondary > td {\n background-color: #d6d8db;\n}\n\n.table-secondary th,\n.table-secondary td,\n.table-secondary thead th,\n.table-secondary tbody + tbody {\n border-color: #b3b7bb;\n}\n\n.table-hover .table-secondary:hover {\n background-color: #c8cbcf;\n}\n\n.table-hover .table-secondary:hover > td,\n.table-hover .table-secondary:hover > th {\n background-color: #c8cbcf;\n}\n\n.table-success,\n.table-success > th,\n.table-success > td {\n background-color: #c3e6cb;\n}\n\n.table-success th,\n.table-success td,\n.table-success thead th,\n.table-success tbody + tbody {\n border-color: #8fd19e;\n}\n\n.table-hover .table-success:hover {\n background-color: #b1dfbb;\n}\n\n.table-hover .table-success:hover > td,\n.table-hover .table-success:hover > th {\n background-color: #b1dfbb;\n}\n\n.table-info,\n.table-info > th,\n.table-info > td {\n background-color: #bee5eb;\n}\n\n.table-info th,\n.table-info td,\n.table-info thead th,\n.table-info tbody + tbody {\n border-color: #86cfda;\n}\n\n.table-hover .table-info:hover {\n background-color: #abdde5;\n}\n\n.table-hover .table-info:hover > td,\n.table-hover .table-info:hover > th {\n background-color: #abdde5;\n}\n\n.table-warning,\n.table-warning > th,\n.table-warning > td {\n background-color: #ffeeba;\n}\n\n.table-warning th,\n.table-warning td,\n.table-warning thead th,\n.table-warning tbody + tbody {\n border-color: #ffdf7e;\n}\n\n.table-hover .table-warning:hover {\n background-color: #ffe8a1;\n}\n\n.table-hover .table-warning:hover > td,\n.table-hover .table-warning:hover > th {\n background-color: #ffe8a1;\n}\n\n.table-danger,\n.table-danger > th,\n.table-danger > td {\n background-color: #f5c6cb;\n}\n\n.table-danger th,\n.table-danger td,\n.table-danger thead th,\n.table-danger tbody + tbody {\n border-color: #ed969e;\n}\n\n.table-hover .table-danger:hover {\n background-color: #f1b0b7;\n}\n\n.table-hover .table-danger:hover > td,\n.table-hover .table-danger:hover > th {\n background-color: #f1b0b7;\n}\n\n.table-light,\n.table-light > th,\n.table-light > td {\n background-color: #fdfdfe;\n}\n\n.table-light th,\n.table-light td,\n.table-light thead th,\n.table-light tbody + tbody {\n border-color: #fbfcfc;\n}\n\n.table-hover .table-light:hover {\n background-color: #ececf6;\n}\n\n.table-hover .table-light:hover > td,\n.table-hover .table-light:hover > th {\n background-color: #ececf6;\n}\n\n.table-dark,\n.table-dark > th,\n.table-dark > td {\n background-color: #c6c8ca;\n}\n\n.table-dark th,\n.table-dark td,\n.table-dark thead th,\n.table-dark tbody + tbody {\n border-color: #95999c;\n}\n\n.table-hover .table-dark:hover {\n background-color: #b9bbbe;\n}\n\n.table-hover .table-dark:hover > td,\n.table-hover .table-dark:hover > th {\n background-color: #b9bbbe;\n}\n\n.table-active,\n.table-active > th,\n.table-active > td {\n background-color: rgba(0, 0, 0, 0.075);\n}\n\n.table-hover .table-active:hover {\n background-color: rgba(0, 0, 0, 0.075);\n}\n\n.table-hover .table-active:hover > td,\n.table-hover .table-active:hover > th {\n background-color: rgba(0, 0, 0, 0.075);\n}\n\n.table .thead-dark th {\n color: #fff;\n background-color: #343a40;\n border-color: #454d55;\n}\n\n.table .thead-light th {\n color: #495057;\n background-color: #e9ecef;\n border-color: #dee2e6;\n}\n\n.table-dark {\n color: #fff;\n background-color: #343a40;\n}\n\n.table-dark th,\n.table-dark td,\n.table-dark thead th {\n border-color: #454d55;\n}\n\n.table-dark.table-bordered {\n border: 0;\n}\n\n.table-dark.table-striped tbody tr:nth-of-type(odd) {\n background-color: rgba(255, 255, 255, 0.05);\n}\n\n.table-dark.table-hover tbody tr:hover {\n color: #fff;\n background-color: rgba(255, 255, 255, 0.075);\n}\n\n@media (max-width: 575.98px) {\n .table-responsive-sm {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n .table-responsive-sm > .table-bordered {\n border: 0;\n }\n}\n\n@media (max-width: 767.98px) {\n .table-responsive-md {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n .table-responsive-md > .table-bordered {\n border: 0;\n }\n}\n\n@media (max-width: 991.98px) {\n .table-responsive-lg {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n .table-responsive-lg > .table-bordered {\n border: 0;\n }\n}\n\n@media (max-width: 1199.98px) {\n .table-responsive-xl {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n .table-responsive-xl > .table-bordered {\n border: 0;\n }\n}\n\n.table-responsive {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n}\n\n.table-responsive > .table-bordered {\n border: 0;\n}\n\n.form-control {\n display: block;\n width: 100%;\n height: calc(1.5em + 0.75rem + 2px);\n padding: 0.375rem 0.75rem;\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #495057;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .form-control {\n transition: none;\n }\n}\n\n.form-control::-ms-expand {\n background-color: transparent;\n border: 0;\n}\n\n.form-control:focus {\n color: #495057;\n background-color: #fff;\n border-color: #80bdff;\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.form-control::placeholder {\n color: #6c757d;\n opacity: 1;\n}\n\n.form-control:disabled, .form-control[readonly] {\n background-color: #e9ecef;\n opacity: 1;\n}\n\nselect.form-control:focus::-ms-value {\n color: #495057;\n background-color: #fff;\n}\n\n.form-control-file,\n.form-control-range {\n display: block;\n width: 100%;\n}\n\n.col-form-label {\n padding-top: calc(0.375rem + 1px);\n padding-bottom: calc(0.375rem + 1px);\n margin-bottom: 0;\n font-size: inherit;\n line-height: 1.5;\n}\n\n.col-form-label-lg {\n padding-top: calc(0.5rem + 1px);\n padding-bottom: calc(0.5rem + 1px);\n font-size: 1.25rem;\n line-height: 1.5;\n}\n\n.col-form-label-sm {\n padding-top: calc(0.25rem + 1px);\n padding-bottom: calc(0.25rem + 1px);\n font-size: 0.875rem;\n line-height: 1.5;\n}\n\n.form-control-plaintext {\n display: block;\n width: 100%;\n padding-top: 0.375rem;\n padding-bottom: 0.375rem;\n margin-bottom: 0;\n line-height: 1.5;\n color: #212529;\n background-color: transparent;\n border: solid transparent;\n border-width: 1px 0;\n}\n\n.form-control-plaintext.form-control-sm, .form-control-plaintext.form-control-lg {\n padding-right: 0;\n padding-left: 0;\n}\n\n.form-control-sm {\n height: calc(1.5em + 0.5rem + 2px);\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n line-height: 1.5;\n border-radius: 0.2rem;\n}\n\n.form-control-lg {\n height: calc(1.5em + 1rem + 2px);\n padding: 0.5rem 1rem;\n font-size: 1.25rem;\n line-height: 1.5;\n border-radius: 0.3rem;\n}\n\nselect.form-control[size], select.form-control[multiple] {\n height: auto;\n}\n\ntextarea.form-control {\n height: auto;\n}\n\n.form-group {\n margin-bottom: 1rem;\n}\n\n.form-text {\n display: block;\n margin-top: 0.25rem;\n}\n\n.form-row {\n display: flex;\n flex-wrap: wrap;\n margin-right: -5px;\n margin-left: -5px;\n}\n\n.form-row > .col,\n.form-row > [class*=\"col-\"] {\n padding-right: 5px;\n padding-left: 5px;\n}\n\n.form-check {\n position: relative;\n display: block;\n padding-left: 1.25rem;\n}\n\n.form-check-input {\n position: absolute;\n margin-top: 0.3rem;\n margin-left: -1.25rem;\n}\n\n.form-check-input:disabled ~ .form-check-label {\n color: #6c757d;\n}\n\n.form-check-label {\n margin-bottom: 0;\n}\n\n.form-check-inline {\n display: inline-flex;\n align-items: center;\n padding-left: 0;\n margin-right: 0.75rem;\n}\n\n.form-check-inline .form-check-input {\n position: static;\n margin-top: 0;\n margin-right: 0.3125rem;\n margin-left: 0;\n}\n\n.valid-feedback {\n display: none;\n width: 100%;\n margin-top: 0.25rem;\n font-size: 80%;\n color: #28a745;\n}\n\n.valid-tooltip {\n position: absolute;\n top: 100%;\n z-index: 5;\n display: none;\n max-width: 100%;\n padding: 0.25rem 0.5rem;\n margin-top: .1rem;\n font-size: 0.875rem;\n line-height: 1.5;\n color: #fff;\n background-color: rgba(40, 167, 69, 0.9);\n border-radius: 0.25rem;\n}\n\n.was-validated .form-control:valid, .form-control.is-valid {\n border-color: #28a745;\n padding-right: calc(1.5em + 0.75rem);\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e\");\n background-repeat: no-repeat;\n background-position: center right calc(0.375em + 0.1875rem);\n background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n\n.was-validated .form-control:valid:focus, .form-control.is-valid:focus {\n border-color: #28a745;\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);\n}\n\n.was-validated .form-control:valid ~ .valid-feedback,\n.was-validated .form-control:valid ~ .valid-tooltip, .form-control.is-valid ~ .valid-feedback,\n.form-control.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated textarea.form-control:valid, textarea.form-control.is-valid {\n padding-right: calc(1.5em + 0.75rem);\n background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem);\n}\n\n.was-validated .custom-select:valid, .custom-select.is-valid {\n border-color: #28a745;\n padding-right: calc((1em + 0.75rem) * 3 / 4 + 1.75rem);\n background: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e\") no-repeat right 0.75rem center/8px 10px, url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e\") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n\n.was-validated .custom-select:valid:focus, .custom-select.is-valid:focus {\n border-color: #28a745;\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);\n}\n\n.was-validated .custom-select:valid ~ .valid-feedback,\n.was-validated .custom-select:valid ~ .valid-tooltip, .custom-select.is-valid ~ .valid-feedback,\n.custom-select.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .form-control-file:valid ~ .valid-feedback,\n.was-validated .form-control-file:valid ~ .valid-tooltip, .form-control-file.is-valid ~ .valid-feedback,\n.form-control-file.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label {\n color: #28a745;\n}\n\n.was-validated .form-check-input:valid ~ .valid-feedback,\n.was-validated .form-check-input:valid ~ .valid-tooltip, .form-check-input.is-valid ~ .valid-feedback,\n.form-check-input.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .custom-control-input:valid ~ .custom-control-label, .custom-control-input.is-valid ~ .custom-control-label {\n color: #28a745;\n}\n\n.was-validated .custom-control-input:valid ~ .custom-control-label::before, .custom-control-input.is-valid ~ .custom-control-label::before {\n border-color: #28a745;\n}\n\n.was-validated .custom-control-input:valid ~ .valid-feedback,\n.was-validated .custom-control-input:valid ~ .valid-tooltip, .custom-control-input.is-valid ~ .valid-feedback,\n.custom-control-input.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .custom-control-input:valid:checked ~ .custom-control-label::before, .custom-control-input.is-valid:checked ~ .custom-control-label::before {\n border-color: #34ce57;\n background-color: #34ce57;\n}\n\n.was-validated .custom-control-input:valid:focus ~ .custom-control-label::before, .custom-control-input.is-valid:focus ~ .custom-control-label::before {\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);\n}\n\n.was-validated .custom-control-input:valid:focus:not(:checked) ~ .custom-control-label::before, .custom-control-input.is-valid:focus:not(:checked) ~ .custom-control-label::before {\n border-color: #28a745;\n}\n\n.was-validated .custom-file-input:valid ~ .custom-file-label, .custom-file-input.is-valid ~ .custom-file-label {\n border-color: #28a745;\n}\n\n.was-validated .custom-file-input:valid ~ .valid-feedback,\n.was-validated .custom-file-input:valid ~ .valid-tooltip, .custom-file-input.is-valid ~ .valid-feedback,\n.custom-file-input.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .custom-file-input:valid:focus ~ .custom-file-label, .custom-file-input.is-valid:focus ~ .custom-file-label {\n border-color: #28a745;\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);\n}\n\n.invalid-feedback {\n display: none;\n width: 100%;\n margin-top: 0.25rem;\n font-size: 80%;\n color: #dc3545;\n}\n\n.invalid-tooltip {\n position: absolute;\n top: 100%;\n z-index: 5;\n display: none;\n max-width: 100%;\n padding: 0.25rem 0.5rem;\n margin-top: .1rem;\n font-size: 0.875rem;\n line-height: 1.5;\n color: #fff;\n background-color: rgba(220, 53, 69, 0.9);\n border-radius: 0.25rem;\n}\n\n.was-validated .form-control:invalid, .form-control.is-invalid {\n border-color: #dc3545;\n padding-right: calc(1.5em + 0.75rem);\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23dc3545' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23dc3545' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E\");\n background-repeat: no-repeat;\n background-position: center right calc(0.375em + 0.1875rem);\n background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n\n.was-validated .form-control:invalid:focus, .form-control.is-invalid:focus {\n border-color: #dc3545;\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);\n}\n\n.was-validated .form-control:invalid ~ .invalid-feedback,\n.was-validated .form-control:invalid ~ .invalid-tooltip, .form-control.is-invalid ~ .invalid-feedback,\n.form-control.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated textarea.form-control:invalid, textarea.form-control.is-invalid {\n padding-right: calc(1.5em + 0.75rem);\n background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem);\n}\n\n.was-validated .custom-select:invalid, .custom-select.is-invalid {\n border-color: #dc3545;\n padding-right: calc((1em + 0.75rem) * 3 / 4 + 1.75rem);\n background: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e\") no-repeat right 0.75rem center/8px 10px, url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23dc3545' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23dc3545' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E\") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n\n.was-validated .custom-select:invalid:focus, .custom-select.is-invalid:focus {\n border-color: #dc3545;\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);\n}\n\n.was-validated .custom-select:invalid ~ .invalid-feedback,\n.was-validated .custom-select:invalid ~ .invalid-tooltip, .custom-select.is-invalid ~ .invalid-feedback,\n.custom-select.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .form-control-file:invalid ~ .invalid-feedback,\n.was-validated .form-control-file:invalid ~ .invalid-tooltip, .form-control-file.is-invalid ~ .invalid-feedback,\n.form-control-file.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label {\n color: #dc3545;\n}\n\n.was-validated .form-check-input:invalid ~ .invalid-feedback,\n.was-validated .form-check-input:invalid ~ .invalid-tooltip, .form-check-input.is-invalid ~ .invalid-feedback,\n.form-check-input.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .custom-control-input:invalid ~ .custom-control-label, .custom-control-input.is-invalid ~ .custom-control-label {\n color: #dc3545;\n}\n\n.was-validated .custom-control-input:invalid ~ .custom-control-label::before, .custom-control-input.is-invalid ~ .custom-control-label::before {\n border-color: #dc3545;\n}\n\n.was-validated .custom-control-input:invalid ~ .invalid-feedback,\n.was-validated .custom-control-input:invalid ~ .invalid-tooltip, .custom-control-input.is-invalid ~ .invalid-feedback,\n.custom-control-input.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .custom-control-input:invalid:checked ~ .custom-control-label::before, .custom-control-input.is-invalid:checked ~ .custom-control-label::before {\n border-color: #e4606d;\n background-color: #e4606d;\n}\n\n.was-validated .custom-control-input:invalid:focus ~ .custom-control-label::before, .custom-control-input.is-invalid:focus ~ .custom-control-label::before {\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);\n}\n\n.was-validated .custom-control-input:invalid:focus:not(:checked) ~ .custom-control-label::before, .custom-control-input.is-invalid:focus:not(:checked) ~ .custom-control-label::before {\n border-color: #dc3545;\n}\n\n.was-validated .custom-file-input:invalid ~ .custom-file-label, .custom-file-input.is-invalid ~ .custom-file-label {\n border-color: #dc3545;\n}\n\n.was-validated .custom-file-input:invalid ~ .invalid-feedback,\n.was-validated .custom-file-input:invalid ~ .invalid-tooltip, .custom-file-input.is-invalid ~ .invalid-feedback,\n.custom-file-input.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .custom-file-input:invalid:focus ~ .custom-file-label, .custom-file-input.is-invalid:focus ~ .custom-file-label {\n border-color: #dc3545;\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);\n}\n\n.form-inline {\n display: flex;\n flex-flow: row wrap;\n align-items: center;\n}\n\n.form-inline .form-check {\n width: 100%;\n}\n\n@media (min-width: 576px) {\n .form-inline label {\n display: flex;\n align-items: center;\n justify-content: center;\n margin-bottom: 0;\n }\n .form-inline .form-group {\n display: flex;\n flex: 0 0 auto;\n flex-flow: row wrap;\n align-items: center;\n margin-bottom: 0;\n }\n .form-inline .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .form-inline .form-control-plaintext {\n display: inline-block;\n }\n .form-inline .input-group,\n .form-inline .custom-select {\n width: auto;\n }\n .form-inline .form-check {\n display: flex;\n align-items: center;\n justify-content: center;\n width: auto;\n padding-left: 0;\n }\n .form-inline .form-check-input {\n position: relative;\n flex-shrink: 0;\n margin-top: 0;\n margin-right: 0.25rem;\n margin-left: 0;\n }\n .form-inline .custom-control {\n align-items: center;\n justify-content: center;\n }\n .form-inline .custom-control-label {\n margin-bottom: 0;\n }\n}\n\n.btn {\n display: inline-block;\n font-weight: 400;\n color: #212529;\n text-align: center;\n vertical-align: middle;\n user-select: none;\n background-color: transparent;\n border: 1px solid transparent;\n padding: 0.375rem 0.75rem;\n font-size: 1rem;\n line-height: 1.5;\n border-radius: 0.25rem;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .btn {\n transition: none;\n }\n}\n\n.btn:hover {\n color: #212529;\n text-decoration: none;\n}\n\n.btn:focus, .btn.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.btn.disabled, .btn:disabled {\n opacity: 0.65;\n}\n\na.btn.disabled,\nfieldset:disabled a.btn {\n pointer-events: none;\n}\n\n.btn-primary {\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.btn-primary:hover {\n color: #fff;\n background-color: #0069d9;\n border-color: #0062cc;\n}\n\n.btn-primary:focus, .btn-primary.focus {\n box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5);\n}\n\n.btn-primary.disabled, .btn-primary:disabled {\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.btn-primary:not(:disabled):not(.disabled):active, .btn-primary:not(:disabled):not(.disabled).active,\n.show > .btn-primary.dropdown-toggle {\n color: #fff;\n background-color: #0062cc;\n border-color: #005cbf;\n}\n\n.btn-primary:not(:disabled):not(.disabled):active:focus, .btn-primary:not(:disabled):not(.disabled).active:focus,\n.show > .btn-primary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5);\n}\n\n.btn-secondary {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-secondary:hover {\n color: #fff;\n background-color: #5a6268;\n border-color: #545b62;\n}\n\n.btn-secondary:focus, .btn-secondary.focus {\n box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5);\n}\n\n.btn-secondary.disabled, .btn-secondary:disabled {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-secondary:not(:disabled):not(.disabled):active, .btn-secondary:not(:disabled):not(.disabled).active,\n.show > .btn-secondary.dropdown-toggle {\n color: #fff;\n background-color: #545b62;\n border-color: #4e555b;\n}\n\n.btn-secondary:not(:disabled):not(.disabled):active:focus, .btn-secondary:not(:disabled):not(.disabled).active:focus,\n.show > .btn-secondary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5);\n}\n\n.btn-success {\n color: #fff;\n background-color: #28a745;\n border-color: #28a745;\n}\n\n.btn-success:hover {\n color: #fff;\n background-color: #218838;\n border-color: #1e7e34;\n}\n\n.btn-success:focus, .btn-success.focus {\n box-shadow: 0 0 0 0.2rem rgba(72, 180, 97, 0.5);\n}\n\n.btn-success.disabled, .btn-success:disabled {\n color: #fff;\n background-color: #28a745;\n border-color: #28a745;\n}\n\n.btn-success:not(:disabled):not(.disabled):active, .btn-success:not(:disabled):not(.disabled).active,\n.show > .btn-success.dropdown-toggle {\n color: #fff;\n background-color: #1e7e34;\n border-color: #1c7430;\n}\n\n.btn-success:not(:disabled):not(.disabled):active:focus, .btn-success:not(:disabled):not(.disabled).active:focus,\n.show > .btn-success.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(72, 180, 97, 0.5);\n}\n\n.btn-info {\n color: #fff;\n background-color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-info:hover {\n color: #fff;\n background-color: #138496;\n border-color: #117a8b;\n}\n\n.btn-info:focus, .btn-info.focus {\n box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5);\n}\n\n.btn-info.disabled, .btn-info:disabled {\n color: #fff;\n background-color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-info:not(:disabled):not(.disabled):active, .btn-info:not(:disabled):not(.disabled).active,\n.show > .btn-info.dropdown-toggle {\n color: #fff;\n background-color: #117a8b;\n border-color: #10707f;\n}\n\n.btn-info:not(:disabled):not(.disabled):active:focus, .btn-info:not(:disabled):not(.disabled).active:focus,\n.show > .btn-info.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5);\n}\n\n.btn-warning {\n color: #212529;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-warning:hover {\n color: #212529;\n background-color: #e0a800;\n border-color: #d39e00;\n}\n\n.btn-warning:focus, .btn-warning.focus {\n box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5);\n}\n\n.btn-warning.disabled, .btn-warning:disabled {\n color: #212529;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-warning:not(:disabled):not(.disabled):active, .btn-warning:not(:disabled):not(.disabled).active,\n.show > .btn-warning.dropdown-toggle {\n color: #212529;\n background-color: #d39e00;\n border-color: #c69500;\n}\n\n.btn-warning:not(:disabled):not(.disabled):active:focus, .btn-warning:not(:disabled):not(.disabled).active:focus,\n.show > .btn-warning.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5);\n}\n\n.btn-danger {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-danger:hover {\n color: #fff;\n background-color: #c82333;\n border-color: #bd2130;\n}\n\n.btn-danger:focus, .btn-danger.focus {\n box-shadow: 0 0 0 0.2rem rgba(225, 83, 97, 0.5);\n}\n\n.btn-danger.disabled, .btn-danger:disabled {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-danger:not(:disabled):not(.disabled):active, .btn-danger:not(:disabled):not(.disabled).active,\n.show > .btn-danger.dropdown-toggle {\n color: #fff;\n background-color: #bd2130;\n border-color: #b21f2d;\n}\n\n.btn-danger:not(:disabled):not(.disabled):active:focus, .btn-danger:not(:disabled):not(.disabled).active:focus,\n.show > .btn-danger.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(225, 83, 97, 0.5);\n}\n\n.btn-light {\n color: #212529;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-light:hover {\n color: #212529;\n background-color: #e2e6ea;\n border-color: #dae0e5;\n}\n\n.btn-light:focus, .btn-light.focus {\n box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5);\n}\n\n.btn-light.disabled, .btn-light:disabled {\n color: #212529;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-light:not(:disabled):not(.disabled):active, .btn-light:not(:disabled):not(.disabled).active,\n.show > .btn-light.dropdown-toggle {\n color: #212529;\n background-color: #dae0e5;\n border-color: #d3d9df;\n}\n\n.btn-light:not(:disabled):not(.disabled):active:focus, .btn-light:not(:disabled):not(.disabled).active:focus,\n.show > .btn-light.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5);\n}\n\n.btn-dark {\n color: #fff;\n background-color: #343a40;\n border-color: #343a40;\n}\n\n.btn-dark:hover {\n color: #fff;\n background-color: #23272b;\n border-color: #1d2124;\n}\n\n.btn-dark:focus, .btn-dark.focus {\n box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5);\n}\n\n.btn-dark.disabled, .btn-dark:disabled {\n color: #fff;\n background-color: #343a40;\n border-color: #343a40;\n}\n\n.btn-dark:not(:disabled):not(.disabled):active, .btn-dark:not(:disabled):not(.disabled).active,\n.show > .btn-dark.dropdown-toggle {\n color: #fff;\n background-color: #1d2124;\n border-color: #171a1d;\n}\n\n.btn-dark:not(:disabled):not(.disabled):active:focus, .btn-dark:not(:disabled):not(.disabled).active:focus,\n.show > .btn-dark.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5);\n}\n\n.btn-outline-primary {\n color: #007bff;\n border-color: #007bff;\n}\n\n.btn-outline-primary:hover {\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.btn-outline-primary:focus, .btn-outline-primary.focus {\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);\n}\n\n.btn-outline-primary.disabled, .btn-outline-primary:disabled {\n color: #007bff;\n background-color: transparent;\n}\n\n.btn-outline-primary:not(:disabled):not(.disabled):active, .btn-outline-primary:not(:disabled):not(.disabled).active,\n.show > .btn-outline-primary.dropdown-toggle {\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.btn-outline-primary:not(:disabled):not(.disabled):active:focus, .btn-outline-primary:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-primary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);\n}\n\n.btn-outline-secondary {\n color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-outline-secondary:hover {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-outline-secondary:focus, .btn-outline-secondary.focus {\n box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);\n}\n\n.btn-outline-secondary.disabled, .btn-outline-secondary:disabled {\n color: #6c757d;\n background-color: transparent;\n}\n\n.btn-outline-secondary:not(:disabled):not(.disabled):active, .btn-outline-secondary:not(:disabled):not(.disabled).active,\n.show > .btn-outline-secondary.dropdown-toggle {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-outline-secondary:not(:disabled):not(.disabled):active:focus, .btn-outline-secondary:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-secondary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);\n}\n\n.btn-outline-success {\n color: #28a745;\n border-color: #28a745;\n}\n\n.btn-outline-success:hover {\n color: #fff;\n background-color: #28a745;\n border-color: #28a745;\n}\n\n.btn-outline-success:focus, .btn-outline-success.focus {\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);\n}\n\n.btn-outline-success.disabled, .btn-outline-success:disabled {\n color: #28a745;\n background-color: transparent;\n}\n\n.btn-outline-success:not(:disabled):not(.disabled):active, .btn-outline-success:not(:disabled):not(.disabled).active,\n.show > .btn-outline-success.dropdown-toggle {\n color: #fff;\n background-color: #28a745;\n border-color: #28a745;\n}\n\n.btn-outline-success:not(:disabled):not(.disabled):active:focus, .btn-outline-success:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-success.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);\n}\n\n.btn-outline-info {\n color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-outline-info:hover {\n color: #fff;\n background-color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-outline-info:focus, .btn-outline-info.focus {\n box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);\n}\n\n.btn-outline-info.disabled, .btn-outline-info:disabled {\n color: #17a2b8;\n background-color: transparent;\n}\n\n.btn-outline-info:not(:disabled):not(.disabled):active, .btn-outline-info:not(:disabled):not(.disabled).active,\n.show > .btn-outline-info.dropdown-toggle {\n color: #fff;\n background-color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-outline-info:not(:disabled):not(.disabled):active:focus, .btn-outline-info:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-info.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);\n}\n\n.btn-outline-warning {\n color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-outline-warning:hover {\n color: #212529;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-outline-warning:focus, .btn-outline-warning.focus {\n box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);\n}\n\n.btn-outline-warning.disabled, .btn-outline-warning:disabled {\n color: #ffc107;\n background-color: transparent;\n}\n\n.btn-outline-warning:not(:disabled):not(.disabled):active, .btn-outline-warning:not(:disabled):not(.disabled).active,\n.show > .btn-outline-warning.dropdown-toggle {\n color: #212529;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-outline-warning:not(:disabled):not(.disabled):active:focus, .btn-outline-warning:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-warning.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);\n}\n\n.btn-outline-danger {\n color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-outline-danger:hover {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-outline-danger:focus, .btn-outline-danger.focus {\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);\n}\n\n.btn-outline-danger.disabled, .btn-outline-danger:disabled {\n color: #dc3545;\n background-color: transparent;\n}\n\n.btn-outline-danger:not(:disabled):not(.disabled):active, .btn-outline-danger:not(:disabled):not(.disabled).active,\n.show > .btn-outline-danger.dropdown-toggle {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-outline-danger:not(:disabled):not(.disabled):active:focus, .btn-outline-danger:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-danger.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);\n}\n\n.btn-outline-light {\n color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-outline-light:hover {\n color: #212529;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-outline-light:focus, .btn-outline-light.focus {\n box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);\n}\n\n.btn-outline-light.disabled, .btn-outline-light:disabled {\n color: #f8f9fa;\n background-color: transparent;\n}\n\n.btn-outline-light:not(:disabled):not(.disabled):active, .btn-outline-light:not(:disabled):not(.disabled).active,\n.show > .btn-outline-light.dropdown-toggle {\n color: #212529;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-outline-light:not(:disabled):not(.disabled):active:focus, .btn-outline-light:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-light.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);\n}\n\n.btn-outline-dark {\n color: #343a40;\n border-color: #343a40;\n}\n\n.btn-outline-dark:hover {\n color: #fff;\n background-color: #343a40;\n border-color: #343a40;\n}\n\n.btn-outline-dark:focus, .btn-outline-dark.focus {\n box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);\n}\n\n.btn-outline-dark.disabled, .btn-outline-dark:disabled {\n color: #343a40;\n background-color: transparent;\n}\n\n.btn-outline-dark:not(:disabled):not(.disabled):active, .btn-outline-dark:not(:disabled):not(.disabled).active,\n.show > .btn-outline-dark.dropdown-toggle {\n color: #fff;\n background-color: #343a40;\n border-color: #343a40;\n}\n\n.btn-outline-dark:not(:disabled):not(.disabled):active:focus, .btn-outline-dark:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-dark.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);\n}\n\n.btn-link {\n font-weight: 400;\n color: #007bff;\n text-decoration: none;\n}\n\n.btn-link:hover {\n color: #0056b3;\n text-decoration: underline;\n}\n\n.btn-link:focus, .btn-link.focus {\n text-decoration: underline;\n box-shadow: none;\n}\n\n.btn-link:disabled, .btn-link.disabled {\n color: #6c757d;\n pointer-events: none;\n}\n\n.btn-lg, .btn-group-lg > .btn {\n padding: 0.5rem 1rem;\n font-size: 1.25rem;\n line-height: 1.5;\n border-radius: 0.3rem;\n}\n\n.btn-sm, .btn-group-sm > .btn {\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n line-height: 1.5;\n border-radius: 0.2rem;\n}\n\n.btn-block {\n display: block;\n width: 100%;\n}\n\n.btn-block + .btn-block {\n margin-top: 0.5rem;\n}\n\ninput[type=\"submit\"].btn-block,\ninput[type=\"reset\"].btn-block,\ninput[type=\"button\"].btn-block {\n width: 100%;\n}\n\n.fade {\n transition: opacity 0.15s linear;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .fade {\n transition: none;\n }\n}\n\n.fade:not(.show) {\n opacity: 0;\n}\n\n.collapse:not(.show) {\n display: none;\n}\n\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n transition: height 0.35s ease;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .collapsing {\n transition: none;\n }\n}\n\n.dropup,\n.dropright,\n.dropdown,\n.dropleft {\n position: relative;\n}\n\n.dropdown-toggle {\n white-space: nowrap;\n}\n\n.dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid;\n border-right: 0.3em solid transparent;\n border-bottom: 0;\n border-left: 0.3em solid transparent;\n}\n\n.dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: 1000;\n display: none;\n float: left;\n min-width: 10rem;\n padding: 0.5rem 0;\n margin: 0.125rem 0 0;\n font-size: 1rem;\n color: #212529;\n text-align: left;\n list-style: none;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.15);\n border-radius: 0.25rem;\n}\n\n.dropdown-menu-left {\n right: auto;\n left: 0;\n}\n\n.dropdown-menu-right {\n right: 0;\n left: auto;\n}\n\n@media (min-width: 576px) {\n .dropdown-menu-sm-left {\n right: auto;\n left: 0;\n }\n .dropdown-menu-sm-right {\n right: 0;\n left: auto;\n }\n}\n\n@media (min-width: 768px) {\n .dropdown-menu-md-left {\n right: auto;\n left: 0;\n }\n .dropdown-menu-md-right {\n right: 0;\n left: auto;\n }\n}\n\n@media (min-width: 992px) {\n .dropdown-menu-lg-left {\n right: auto;\n left: 0;\n }\n .dropdown-menu-lg-right {\n right: 0;\n left: auto;\n }\n}\n\n@media (min-width: 1200px) {\n .dropdown-menu-xl-left {\n right: auto;\n left: 0;\n }\n .dropdown-menu-xl-right {\n right: 0;\n left: auto;\n }\n}\n\n.dropup .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-top: 0;\n margin-bottom: 0.125rem;\n}\n\n.dropup .dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0;\n border-right: 0.3em solid transparent;\n border-bottom: 0.3em solid;\n border-left: 0.3em solid transparent;\n}\n\n.dropup .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropright .dropdown-menu {\n top: 0;\n right: auto;\n left: 100%;\n margin-top: 0;\n margin-left: 0.125rem;\n}\n\n.dropright .dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid transparent;\n border-right: 0;\n border-bottom: 0.3em solid transparent;\n border-left: 0.3em solid;\n}\n\n.dropright .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropright .dropdown-toggle::after {\n vertical-align: 0;\n}\n\n.dropleft .dropdown-menu {\n top: 0;\n right: 100%;\n left: auto;\n margin-top: 0;\n margin-right: 0.125rem;\n}\n\n.dropleft .dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n}\n\n.dropleft .dropdown-toggle::after {\n display: none;\n}\n\n.dropleft .dropdown-toggle::before {\n display: inline-block;\n margin-right: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid transparent;\n border-right: 0.3em solid;\n border-bottom: 0.3em solid transparent;\n}\n\n.dropleft .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropleft .dropdown-toggle::before {\n vertical-align: 0;\n}\n\n.dropdown-menu[x-placement^=\"top\"], .dropdown-menu[x-placement^=\"right\"], .dropdown-menu[x-placement^=\"bottom\"], .dropdown-menu[x-placement^=\"left\"] {\n right: auto;\n bottom: auto;\n}\n\n.dropdown-divider {\n height: 0;\n margin: 0.5rem 0;\n overflow: hidden;\n border-top: 1px solid #e9ecef;\n}\n\n.dropdown-item {\n display: block;\n width: 100%;\n padding: 0.25rem 1.5rem;\n clear: both;\n font-weight: 400;\n color: #212529;\n text-align: inherit;\n white-space: nowrap;\n background-color: transparent;\n border: 0;\n}\n\n.dropdown-item:hover, .dropdown-item:focus {\n color: #16181b;\n text-decoration: none;\n background-color: #f8f9fa;\n}\n\n.dropdown-item.active, .dropdown-item:active {\n color: #fff;\n text-decoration: none;\n background-color: #007bff;\n}\n\n.dropdown-item.disabled, .dropdown-item:disabled {\n color: #6c757d;\n pointer-events: none;\n background-color: transparent;\n}\n\n.dropdown-menu.show {\n display: block;\n}\n\n.dropdown-header {\n display: block;\n padding: 0.5rem 1.5rem;\n margin-bottom: 0;\n font-size: 0.875rem;\n color: #6c757d;\n white-space: nowrap;\n}\n\n.dropdown-item-text {\n display: block;\n padding: 0.25rem 1.5rem;\n color: #212529;\n}\n\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-flex;\n vertical-align: middle;\n}\n\n.btn-group > .btn,\n.btn-group-vertical > .btn {\n position: relative;\n flex: 1 1 auto;\n}\n\n.btn-group > .btn:hover,\n.btn-group-vertical > .btn:hover {\n z-index: 1;\n}\n\n.btn-group > .btn:focus, .btn-group > .btn:active, .btn-group > .btn.active,\n.btn-group-vertical > .btn:focus,\n.btn-group-vertical > .btn:active,\n.btn-group-vertical > .btn.active {\n z-index: 1;\n}\n\n.btn-toolbar {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-start;\n}\n\n.btn-toolbar .input-group {\n width: auto;\n}\n\n.btn-group > .btn:not(:first-child),\n.btn-group > .btn-group:not(:first-child) {\n margin-left: -1px;\n}\n\n.btn-group > .btn:not(:last-child):not(.dropdown-toggle),\n.btn-group > .btn-group:not(:last-child) > .btn {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n\n.btn-group > .btn:not(:first-child),\n.btn-group > .btn-group:not(:first-child) > .btn {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.dropdown-toggle-split {\n padding-right: 0.5625rem;\n padding-left: 0.5625rem;\n}\n\n.dropdown-toggle-split::after,\n.dropup .dropdown-toggle-split::after,\n.dropright .dropdown-toggle-split::after {\n margin-left: 0;\n}\n\n.dropleft .dropdown-toggle-split::before {\n margin-right: 0;\n}\n\n.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split {\n padding-right: 0.375rem;\n padding-left: 0.375rem;\n}\n\n.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split {\n padding-right: 0.75rem;\n padding-left: 0.75rem;\n}\n\n.btn-group-vertical {\n flex-direction: column;\n align-items: flex-start;\n justify-content: center;\n}\n\n.btn-group-vertical > .btn,\n.btn-group-vertical > .btn-group {\n width: 100%;\n}\n\n.btn-group-vertical > .btn:not(:first-child),\n.btn-group-vertical > .btn-group:not(:first-child) {\n margin-top: -1px;\n}\n\n.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle),\n.btn-group-vertical > .btn-group:not(:last-child) > .btn {\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.btn-group-vertical > .btn:not(:first-child),\n.btn-group-vertical > .btn-group:not(:first-child) > .btn {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n\n.btn-group-toggle > .btn,\n.btn-group-toggle > .btn-group > .btn {\n margin-bottom: 0;\n}\n\n.btn-group-toggle > .btn input[type=\"radio\"],\n.btn-group-toggle > .btn input[type=\"checkbox\"],\n.btn-group-toggle > .btn-group > .btn input[type=\"radio\"],\n.btn-group-toggle > .btn-group > .btn input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n}\n\n.input-group {\n position: relative;\n display: flex;\n flex-wrap: wrap;\n align-items: stretch;\n width: 100%;\n}\n\n.input-group > .form-control,\n.input-group > .form-control-plaintext,\n.input-group > .custom-select,\n.input-group > .custom-file {\n position: relative;\n flex: 1 1 auto;\n width: 1%;\n margin-bottom: 0;\n}\n\n.input-group > .form-control + .form-control,\n.input-group > .form-control + .custom-select,\n.input-group > .form-control + .custom-file,\n.input-group > .form-control-plaintext + .form-control,\n.input-group > .form-control-plaintext + .custom-select,\n.input-group > .form-control-plaintext + .custom-file,\n.input-group > .custom-select + .form-control,\n.input-group > .custom-select + .custom-select,\n.input-group > .custom-select + .custom-file,\n.input-group > .custom-file + .form-control,\n.input-group > .custom-file + .custom-select,\n.input-group > .custom-file + .custom-file {\n margin-left: -1px;\n}\n\n.input-group > .form-control:focus,\n.input-group > .custom-select:focus,\n.input-group > .custom-file .custom-file-input:focus ~ .custom-file-label {\n z-index: 3;\n}\n\n.input-group > .custom-file .custom-file-input:focus {\n z-index: 4;\n}\n\n.input-group > .form-control:not(:last-child),\n.input-group > .custom-select:not(:last-child) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n\n.input-group > .form-control:not(:first-child),\n.input-group > .custom-select:not(:first-child) {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.input-group > .custom-file {\n display: flex;\n align-items: center;\n}\n\n.input-group > .custom-file:not(:last-child) .custom-file-label,\n.input-group > .custom-file:not(:last-child) .custom-file-label::after {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n\n.input-group > .custom-file:not(:first-child) .custom-file-label {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.input-group-prepend,\n.input-group-append {\n display: flex;\n}\n\n.input-group-prepend .btn,\n.input-group-append .btn {\n position: relative;\n z-index: 2;\n}\n\n.input-group-prepend .btn:focus,\n.input-group-append .btn:focus {\n z-index: 3;\n}\n\n.input-group-prepend .btn + .btn,\n.input-group-prepend .btn + .input-group-text,\n.input-group-prepend .input-group-text + .input-group-text,\n.input-group-prepend .input-group-text + .btn,\n.input-group-append .btn + .btn,\n.input-group-append .btn + .input-group-text,\n.input-group-append .input-group-text + .input-group-text,\n.input-group-append .input-group-text + .btn {\n margin-left: -1px;\n}\n\n.input-group-prepend {\n margin-right: -1px;\n}\n\n.input-group-append {\n margin-left: -1px;\n}\n\n.input-group-text {\n display: flex;\n align-items: center;\n padding: 0.375rem 0.75rem;\n margin-bottom: 0;\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #495057;\n text-align: center;\n white-space: nowrap;\n background-color: #e9ecef;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n}\n\n.input-group-text input[type=\"radio\"],\n.input-group-text input[type=\"checkbox\"] {\n margin-top: 0;\n}\n\n.input-group-lg > .form-control:not(textarea),\n.input-group-lg > .custom-select {\n height: calc(1.5em + 1rem + 2px);\n}\n\n.input-group-lg > .form-control,\n.input-group-lg > .custom-select,\n.input-group-lg > .input-group-prepend > .input-group-text,\n.input-group-lg > .input-group-append > .input-group-text,\n.input-group-lg > .input-group-prepend > .btn,\n.input-group-lg > .input-group-append > .btn {\n padding: 0.5rem 1rem;\n font-size: 1.25rem;\n line-height: 1.5;\n border-radius: 0.3rem;\n}\n\n.input-group-sm > .form-control:not(textarea),\n.input-group-sm > .custom-select {\n height: calc(1.5em + 0.5rem + 2px);\n}\n\n.input-group-sm > .form-control,\n.input-group-sm > .custom-select,\n.input-group-sm > .input-group-prepend > .input-group-text,\n.input-group-sm > .input-group-append > .input-group-text,\n.input-group-sm > .input-group-prepend > .btn,\n.input-group-sm > .input-group-append > .btn {\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n line-height: 1.5;\n border-radius: 0.2rem;\n}\n\n.input-group-lg > .custom-select,\n.input-group-sm > .custom-select {\n padding-right: 1.75rem;\n}\n\n.input-group > .input-group-prepend > .btn,\n.input-group > .input-group-prepend > .input-group-text,\n.input-group > .input-group-append:not(:last-child) > .btn,\n.input-group > .input-group-append:not(:last-child) > .input-group-text,\n.input-group > .input-group-append:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group > .input-group-append:last-child > .input-group-text:not(:last-child) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n\n.input-group > .input-group-append > .btn,\n.input-group > .input-group-append > .input-group-text,\n.input-group > .input-group-prepend:not(:first-child) > .btn,\n.input-group > .input-group-prepend:not(:first-child) > .input-group-text,\n.input-group > .input-group-prepend:first-child > .btn:not(:first-child),\n.input-group > .input-group-prepend:first-child > .input-group-text:not(:first-child) {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.custom-control {\n position: relative;\n display: block;\n min-height: 1.5rem;\n padding-left: 1.5rem;\n}\n\n.custom-control-inline {\n display: inline-flex;\n margin-right: 1rem;\n}\n\n.custom-control-input {\n position: absolute;\n z-index: -1;\n opacity: 0;\n}\n\n.custom-control-input:checked ~ .custom-control-label::before {\n color: #fff;\n border-color: #007bff;\n background-color: #007bff;\n}\n\n.custom-control-input:focus ~ .custom-control-label::before {\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-control-input:focus:not(:checked) ~ .custom-control-label::before {\n border-color: #80bdff;\n}\n\n.custom-control-input:not(:disabled):active ~ .custom-control-label::before {\n color: #fff;\n background-color: #b3d7ff;\n border-color: #b3d7ff;\n}\n\n.custom-control-input:disabled ~ .custom-control-label {\n color: #6c757d;\n}\n\n.custom-control-input:disabled ~ .custom-control-label::before {\n background-color: #e9ecef;\n}\n\n.custom-control-label {\n position: relative;\n margin-bottom: 0;\n vertical-align: top;\n}\n\n.custom-control-label::before {\n position: absolute;\n top: 0.25rem;\n left: -1.5rem;\n display: block;\n width: 1rem;\n height: 1rem;\n pointer-events: none;\n content: \"\";\n background-color: #fff;\n border: #adb5bd solid 1px;\n}\n\n.custom-control-label::after {\n position: absolute;\n top: 0.25rem;\n left: -1.5rem;\n display: block;\n width: 1rem;\n height: 1rem;\n content: \"\";\n background: no-repeat 50% / 50% 50%;\n}\n\n.custom-checkbox .custom-control-label::before {\n border-radius: 0.25rem;\n}\n\n.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3e%3c/svg%3e\");\n}\n\n.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before {\n border-color: #007bff;\n background-color: #007bff;\n}\n\n.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::after {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e\");\n}\n\n.custom-checkbox .custom-control-input:disabled:checked ~ .custom-control-label::before {\n background-color: rgba(0, 123, 255, 0.5);\n}\n\n.custom-checkbox .custom-control-input:disabled:indeterminate ~ .custom-control-label::before {\n background-color: rgba(0, 123, 255, 0.5);\n}\n\n.custom-radio .custom-control-label::before {\n border-radius: 50%;\n}\n\n.custom-radio .custom-control-input:checked ~ .custom-control-label::after {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e\");\n}\n\n.custom-radio .custom-control-input:disabled:checked ~ .custom-control-label::before {\n background-color: rgba(0, 123, 255, 0.5);\n}\n\n.custom-switch {\n padding-left: 2.25rem;\n}\n\n.custom-switch .custom-control-label::before {\n left: -2.25rem;\n width: 1.75rem;\n pointer-events: all;\n border-radius: 0.5rem;\n}\n\n.custom-switch .custom-control-label::after {\n top: calc(0.25rem + 2px);\n left: calc(-2.25rem + 2px);\n width: calc(1rem - 4px);\n height: calc(1rem - 4px);\n background-color: #adb5bd;\n border-radius: 0.5rem;\n transition: transform 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .custom-switch .custom-control-label::after {\n transition: none;\n }\n}\n\n.custom-switch .custom-control-input:checked ~ .custom-control-label::after {\n background-color: #fff;\n transform: translateX(0.75rem);\n}\n\n.custom-switch .custom-control-input:disabled:checked ~ .custom-control-label::before {\n background-color: rgba(0, 123, 255, 0.5);\n}\n\n.custom-select {\n display: inline-block;\n width: 100%;\n height: calc(1.5em + 0.75rem + 2px);\n padding: 0.375rem 1.75rem 0.375rem 0.75rem;\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #495057;\n vertical-align: middle;\n background: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e\") no-repeat right 0.75rem center/8px 10px;\n background-color: #fff;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n appearance: none;\n}\n\n.custom-select:focus {\n border-color: #80bdff;\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-select:focus::-ms-value {\n color: #495057;\n background-color: #fff;\n}\n\n.custom-select[multiple], .custom-select[size]:not([size=\"1\"]) {\n height: auto;\n padding-right: 0.75rem;\n background-image: none;\n}\n\n.custom-select:disabled {\n color: #6c757d;\n background-color: #e9ecef;\n}\n\n.custom-select::-ms-expand {\n display: none;\n}\n\n.custom-select-sm {\n height: calc(1.5em + 0.5rem + 2px);\n padding-top: 0.25rem;\n padding-bottom: 0.25rem;\n padding-left: 0.5rem;\n font-size: 0.875rem;\n}\n\n.custom-select-lg {\n height: calc(1.5em + 1rem + 2px);\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n padding-left: 1rem;\n font-size: 1.25rem;\n}\n\n.custom-file {\n position: relative;\n display: inline-block;\n width: 100%;\n height: calc(1.5em + 0.75rem + 2px);\n margin-bottom: 0;\n}\n\n.custom-file-input {\n position: relative;\n z-index: 2;\n width: 100%;\n height: calc(1.5em + 0.75rem + 2px);\n margin: 0;\n opacity: 0;\n}\n\n.custom-file-input:focus ~ .custom-file-label {\n border-color: #80bdff;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-file-input:disabled ~ .custom-file-label {\n background-color: #e9ecef;\n}\n\n.custom-file-input:lang(en) ~ .custom-file-label::after {\n content: \"Browse\";\n}\n\n.custom-file-input ~ .custom-file-label[data-browse]::after {\n content: attr(data-browse);\n}\n\n.custom-file-label {\n position: absolute;\n top: 0;\n right: 0;\n left: 0;\n z-index: 1;\n height: calc(1.5em + 0.75rem + 2px);\n padding: 0.375rem 0.75rem;\n font-weight: 400;\n line-height: 1.5;\n color: #495057;\n background-color: #fff;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n}\n\n.custom-file-label::after {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n z-index: 3;\n display: block;\n height: calc(1.5em + 0.75rem);\n padding: 0.375rem 0.75rem;\n line-height: 1.5;\n color: #495057;\n content: \"Browse\";\n background-color: #e9ecef;\n border-left: inherit;\n border-radius: 0 0.25rem 0.25rem 0;\n}\n\n.custom-range {\n width: 100%;\n height: calc(1rem + 0.4rem);\n padding: 0;\n background-color: transparent;\n appearance: none;\n}\n\n.custom-range:focus {\n outline: none;\n}\n\n.custom-range:focus::-webkit-slider-thumb {\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-range:focus::-moz-range-thumb {\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-range:focus::-ms-thumb {\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-range::-moz-focus-outer {\n border: 0;\n}\n\n.custom-range::-webkit-slider-thumb {\n width: 1rem;\n height: 1rem;\n margin-top: -0.25rem;\n background-color: #007bff;\n border: 0;\n border-radius: 1rem;\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n appearance: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .custom-range::-webkit-slider-thumb {\n transition: none;\n }\n}\n\n.custom-range::-webkit-slider-thumb:active {\n background-color: #b3d7ff;\n}\n\n.custom-range::-webkit-slider-runnable-track {\n width: 100%;\n height: 0.5rem;\n color: transparent;\n cursor: pointer;\n background-color: #dee2e6;\n border-color: transparent;\n border-radius: 1rem;\n}\n\n.custom-range::-moz-range-thumb {\n width: 1rem;\n height: 1rem;\n background-color: #007bff;\n border: 0;\n border-radius: 1rem;\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n appearance: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .custom-range::-moz-range-thumb {\n transition: none;\n }\n}\n\n.custom-range::-moz-range-thumb:active {\n background-color: #b3d7ff;\n}\n\n.custom-range::-moz-range-track {\n width: 100%;\n height: 0.5rem;\n color: transparent;\n cursor: pointer;\n background-color: #dee2e6;\n border-color: transparent;\n border-radius: 1rem;\n}\n\n.custom-range::-ms-thumb {\n width: 1rem;\n height: 1rem;\n margin-top: 0;\n margin-right: 0.2rem;\n margin-left: 0.2rem;\n background-color: #007bff;\n border: 0;\n border-radius: 1rem;\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n appearance: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .custom-range::-ms-thumb {\n transition: none;\n }\n}\n\n.custom-range::-ms-thumb:active {\n background-color: #b3d7ff;\n}\n\n.custom-range::-ms-track {\n width: 100%;\n height: 0.5rem;\n color: transparent;\n cursor: pointer;\n background-color: transparent;\n border-color: transparent;\n border-width: 0.5rem;\n}\n\n.custom-range::-ms-fill-lower {\n background-color: #dee2e6;\n border-radius: 1rem;\n}\n\n.custom-range::-ms-fill-upper {\n margin-right: 15px;\n background-color: #dee2e6;\n border-radius: 1rem;\n}\n\n.custom-range:disabled::-webkit-slider-thumb {\n background-color: #adb5bd;\n}\n\n.custom-range:disabled::-webkit-slider-runnable-track {\n cursor: default;\n}\n\n.custom-range:disabled::-moz-range-thumb {\n background-color: #adb5bd;\n}\n\n.custom-range:disabled::-moz-range-track {\n cursor: default;\n}\n\n.custom-range:disabled::-ms-thumb {\n background-color: #adb5bd;\n}\n\n.custom-control-label::before,\n.custom-file-label,\n.custom-select {\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .custom-control-label::before,\n .custom-file-label,\n .custom-select {\n transition: none;\n }\n}\n\n.nav {\n display: flex;\n flex-wrap: wrap;\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n\n.nav-link {\n display: block;\n padding: 0.5rem 1rem;\n}\n\n.nav-link:hover, .nav-link:focus {\n text-decoration: none;\n}\n\n.nav-link.disabled {\n color: #6c757d;\n pointer-events: none;\n cursor: default;\n}\n\n.nav-tabs {\n border-bottom: 1px solid #dee2e6;\n}\n\n.nav-tabs .nav-item {\n margin-bottom: -1px;\n}\n\n.nav-tabs .nav-link {\n border: 1px solid transparent;\n border-top-left-radius: 0.25rem;\n border-top-right-radius: 0.25rem;\n}\n\n.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus {\n border-color: #e9ecef #e9ecef #dee2e6;\n}\n\n.nav-tabs .nav-link.disabled {\n color: #6c757d;\n background-color: transparent;\n border-color: transparent;\n}\n\n.nav-tabs .nav-link.active,\n.nav-tabs .nav-item.show .nav-link {\n color: #495057;\n background-color: #fff;\n border-color: #dee2e6 #dee2e6 #fff;\n}\n\n.nav-tabs .dropdown-menu {\n margin-top: -1px;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n\n.nav-pills .nav-link {\n border-radius: 0.25rem;\n}\n\n.nav-pills .nav-link.active,\n.nav-pills .show > .nav-link {\n color: #fff;\n background-color: #007bff;\n}\n\n.nav-fill .nav-item {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.nav-justified .nav-item {\n flex-basis: 0;\n flex-grow: 1;\n text-align: center;\n}\n\n.tab-content > .tab-pane {\n display: none;\n}\n\n.tab-content > .active {\n display: block;\n}\n\n.navbar {\n position: relative;\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n padding: 0.5rem 1rem;\n}\n\n.navbar > .container,\n.navbar > .container-fluid {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n}\n\n.navbar-brand {\n display: inline-block;\n padding-top: 0.3125rem;\n padding-bottom: 0.3125rem;\n margin-right: 1rem;\n font-size: 1.25rem;\n line-height: inherit;\n white-space: nowrap;\n}\n\n.navbar-brand:hover, .navbar-brand:focus {\n text-decoration: none;\n}\n\n.navbar-nav {\n display: flex;\n flex-direction: column;\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n\n.navbar-nav .nav-link {\n padding-right: 0;\n padding-left: 0;\n}\n\n.navbar-nav .dropdown-menu {\n position: static;\n float: none;\n}\n\n.navbar-text {\n display: inline-block;\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n}\n\n.navbar-collapse {\n flex-basis: 100%;\n flex-grow: 1;\n align-items: center;\n}\n\n.navbar-toggler {\n padding: 0.25rem 0.75rem;\n font-size: 1.25rem;\n line-height: 1;\n background-color: transparent;\n border: 1px solid transparent;\n border-radius: 0.25rem;\n}\n\n.navbar-toggler:hover, .navbar-toggler:focus {\n text-decoration: none;\n}\n\n.navbar-toggler-icon {\n display: inline-block;\n width: 1.5em;\n height: 1.5em;\n vertical-align: middle;\n content: \"\";\n background: no-repeat center center;\n background-size: 100% 100%;\n}\n\n@media (max-width: 575.98px) {\n .navbar-expand-sm > .container,\n .navbar-expand-sm > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n }\n}\n\n@media (min-width: 576px) {\n .navbar-expand-sm {\n flex-flow: row nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-sm .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-sm .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-sm .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-sm > .container,\n .navbar-expand-sm > .container-fluid {\n flex-wrap: nowrap;\n }\n .navbar-expand-sm .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-sm .navbar-toggler {\n display: none;\n }\n}\n\n@media (max-width: 767.98px) {\n .navbar-expand-md > .container,\n .navbar-expand-md > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n }\n}\n\n@media (min-width: 768px) {\n .navbar-expand-md {\n flex-flow: row nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-md .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-md .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-md .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-md > .container,\n .navbar-expand-md > .container-fluid {\n flex-wrap: nowrap;\n }\n .navbar-expand-md .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-md .navbar-toggler {\n display: none;\n }\n}\n\n@media (max-width: 991.98px) {\n .navbar-expand-lg > .container,\n .navbar-expand-lg > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n }\n}\n\n@media (min-width: 992px) {\n .navbar-expand-lg {\n flex-flow: row nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-lg .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-lg .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-lg .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-lg > .container,\n .navbar-expand-lg > .container-fluid {\n flex-wrap: nowrap;\n }\n .navbar-expand-lg .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-lg .navbar-toggler {\n display: none;\n }\n}\n\n@media (max-width: 1199.98px) {\n .navbar-expand-xl > .container,\n .navbar-expand-xl > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n }\n}\n\n@media (min-width: 1200px) {\n .navbar-expand-xl {\n flex-flow: row nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-xl .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-xl .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-xl .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-xl > .container,\n .navbar-expand-xl > .container-fluid {\n flex-wrap: nowrap;\n }\n .navbar-expand-xl .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-xl .navbar-toggler {\n display: none;\n }\n}\n\n.navbar-expand {\n flex-flow: row nowrap;\n justify-content: flex-start;\n}\n\n.navbar-expand > .container,\n.navbar-expand > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n}\n\n.navbar-expand .navbar-nav {\n flex-direction: row;\n}\n\n.navbar-expand .navbar-nav .dropdown-menu {\n position: absolute;\n}\n\n.navbar-expand .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n}\n\n.navbar-expand > .container,\n.navbar-expand > .container-fluid {\n flex-wrap: nowrap;\n}\n\n.navbar-expand .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n}\n\n.navbar-expand .navbar-toggler {\n display: none;\n}\n\n.navbar-light .navbar-brand {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-light .navbar-brand:hover, .navbar-light .navbar-brand:focus {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-light .navbar-nav .nav-link {\n color: rgba(0, 0, 0, 0.5);\n}\n\n.navbar-light .navbar-nav .nav-link:hover, .navbar-light .navbar-nav .nav-link:focus {\n color: rgba(0, 0, 0, 0.7);\n}\n\n.navbar-light .navbar-nav .nav-link.disabled {\n color: rgba(0, 0, 0, 0.3);\n}\n\n.navbar-light .navbar-nav .show > .nav-link,\n.navbar-light .navbar-nav .active > .nav-link,\n.navbar-light .navbar-nav .nav-link.show,\n.navbar-light .navbar-nav .nav-link.active {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-light .navbar-toggler {\n color: rgba(0, 0, 0, 0.5);\n border-color: rgba(0, 0, 0, 0.1);\n}\n\n.navbar-light .navbar-toggler-icon {\n background-image: url(\"data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(0, 0, 0, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e\");\n}\n\n.navbar-light .navbar-text {\n color: rgba(0, 0, 0, 0.5);\n}\n\n.navbar-light .navbar-text a {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-light .navbar-text a:hover, .navbar-light .navbar-text a:focus {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-dark .navbar-brand {\n color: #fff;\n}\n\n.navbar-dark .navbar-brand:hover, .navbar-dark .navbar-brand:focus {\n color: #fff;\n}\n\n.navbar-dark .navbar-nav .nav-link {\n color: rgba(255, 255, 255, 0.5);\n}\n\n.navbar-dark .navbar-nav .nav-link:hover, .navbar-dark .navbar-nav .nav-link:focus {\n color: rgba(255, 255, 255, 0.75);\n}\n\n.navbar-dark .navbar-nav .nav-link.disabled {\n color: rgba(255, 255, 255, 0.25);\n}\n\n.navbar-dark .navbar-nav .show > .nav-link,\n.navbar-dark .navbar-nav .active > .nav-link,\n.navbar-dark .navbar-nav .nav-link.show,\n.navbar-dark .navbar-nav .nav-link.active {\n color: #fff;\n}\n\n.navbar-dark .navbar-toggler {\n color: rgba(255, 255, 255, 0.5);\n border-color: rgba(255, 255, 255, 0.1);\n}\n\n.navbar-dark .navbar-toggler-icon {\n background-image: url(\"data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e\");\n}\n\n.navbar-dark .navbar-text {\n color: rgba(255, 255, 255, 0.5);\n}\n\n.navbar-dark .navbar-text a {\n color: #fff;\n}\n\n.navbar-dark .navbar-text a:hover, .navbar-dark .navbar-text a:focus {\n color: #fff;\n}\n\n.card {\n position: relative;\n display: flex;\n flex-direction: column;\n min-width: 0;\n word-wrap: break-word;\n background-color: #fff;\n background-clip: border-box;\n border: 1px solid rgba(0, 0, 0, 0.125);\n border-radius: 0.25rem;\n}\n\n.card > hr {\n margin-right: 0;\n margin-left: 0;\n}\n\n.card > .list-group:first-child .list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-top-right-radius: 0.25rem;\n}\n\n.card > .list-group:last-child .list-group-item:last-child {\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n}\n\n.card-body {\n flex: 1 1 auto;\n padding: 1.25rem;\n}\n\n.card-title {\n margin-bottom: 0.75rem;\n}\n\n.card-subtitle {\n margin-top: -0.375rem;\n margin-bottom: 0;\n}\n\n.card-text:last-child {\n margin-bottom: 0;\n}\n\n.card-link:hover {\n text-decoration: none;\n}\n\n.card-link + .card-link {\n margin-left: 1.25rem;\n}\n\n.card-header {\n padding: 0.75rem 1.25rem;\n margin-bottom: 0;\n background-color: rgba(0, 0, 0, 0.03);\n border-bottom: 1px solid rgba(0, 0, 0, 0.125);\n}\n\n.card-header:first-child {\n border-radius: calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0;\n}\n\n.card-header + .list-group .list-group-item:first-child {\n border-top: 0;\n}\n\n.card-footer {\n padding: 0.75rem 1.25rem;\n background-color: rgba(0, 0, 0, 0.03);\n border-top: 1px solid rgba(0, 0, 0, 0.125);\n}\n\n.card-footer:last-child {\n border-radius: 0 0 calc(0.25rem - 1px) calc(0.25rem - 1px);\n}\n\n.card-header-tabs {\n margin-right: -0.625rem;\n margin-bottom: -0.75rem;\n margin-left: -0.625rem;\n border-bottom: 0;\n}\n\n.card-header-pills {\n margin-right: -0.625rem;\n margin-left: -0.625rem;\n}\n\n.card-img-overlay {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n padding: 1.25rem;\n}\n\n.card-img {\n width: 100%;\n border-radius: calc(0.25rem - 1px);\n}\n\n.card-img-top {\n width: 100%;\n border-top-left-radius: calc(0.25rem - 1px);\n border-top-right-radius: calc(0.25rem - 1px);\n}\n\n.card-img-bottom {\n width: 100%;\n border-bottom-right-radius: calc(0.25rem - 1px);\n border-bottom-left-radius: calc(0.25rem - 1px);\n}\n\n.card-deck {\n display: flex;\n flex-direction: column;\n}\n\n.card-deck .card {\n margin-bottom: 15px;\n}\n\n@media (min-width: 576px) {\n .card-deck {\n flex-flow: row wrap;\n margin-right: -15px;\n margin-left: -15px;\n }\n .card-deck .card {\n display: flex;\n flex: 1 0 0%;\n flex-direction: column;\n margin-right: 15px;\n margin-bottom: 0;\n margin-left: 15px;\n }\n}\n\n.card-group {\n display: flex;\n flex-direction: column;\n}\n\n.card-group > .card {\n margin-bottom: 15px;\n}\n\n@media (min-width: 576px) {\n .card-group {\n flex-flow: row wrap;\n }\n .card-group > .card {\n flex: 1 0 0%;\n margin-bottom: 0;\n }\n .card-group > .card + .card {\n margin-left: 0;\n border-left: 0;\n }\n .card-group > .card:not(:last-child) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n }\n .card-group > .card:not(:last-child) .card-img-top,\n .card-group > .card:not(:last-child) .card-header {\n border-top-right-radius: 0;\n }\n .card-group > .card:not(:last-child) .card-img-bottom,\n .card-group > .card:not(:last-child) .card-footer {\n border-bottom-right-radius: 0;\n }\n .card-group > .card:not(:first-child) {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n }\n .card-group > .card:not(:first-child) .card-img-top,\n .card-group > .card:not(:first-child) .card-header {\n border-top-left-radius: 0;\n }\n .card-group > .card:not(:first-child) .card-img-bottom,\n .card-group > .card:not(:first-child) .card-footer {\n border-bottom-left-radius: 0;\n }\n}\n\n.card-columns .card {\n margin-bottom: 0.75rem;\n}\n\n@media (min-width: 576px) {\n .card-columns {\n column-count: 3;\n column-gap: 1.25rem;\n orphans: 1;\n widows: 1;\n }\n .card-columns .card {\n display: inline-block;\n width: 100%;\n }\n}\n\n.accordion > .card {\n overflow: hidden;\n}\n\n.accordion > .card:not(:first-of-type) .card-header:first-child {\n border-radius: 0;\n}\n\n.accordion > .card:not(:first-of-type):not(:last-of-type) {\n border-bottom: 0;\n border-radius: 0;\n}\n\n.accordion > .card:first-of-type {\n border-bottom: 0;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.accordion > .card:last-of-type {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n\n.accordion > .card .card-header {\n margin-bottom: -1px;\n}\n\n.breadcrumb {\n display: flex;\n flex-wrap: wrap;\n padding: 0.75rem 1rem;\n margin-bottom: 1rem;\n list-style: none;\n background-color: #e9ecef;\n border-radius: 0.25rem;\n}\n\n.breadcrumb-item + .breadcrumb-item {\n padding-left: 0.5rem;\n}\n\n.breadcrumb-item + .breadcrumb-item::before {\n display: inline-block;\n padding-right: 0.5rem;\n color: #6c757d;\n content: \"/\";\n}\n\n.breadcrumb-item + .breadcrumb-item:hover::before {\n text-decoration: underline;\n}\n\n.breadcrumb-item + .breadcrumb-item:hover::before {\n text-decoration: none;\n}\n\n.breadcrumb-item.active {\n color: #6c757d;\n}\n\n.pagination {\n display: flex;\n padding-left: 0;\n list-style: none;\n border-radius: 0.25rem;\n}\n\n.page-link {\n position: relative;\n display: block;\n padding: 0.5rem 0.75rem;\n margin-left: -1px;\n line-height: 1.25;\n color: #007bff;\n background-color: #fff;\n border: 1px solid #dee2e6;\n}\n\n.page-link:hover {\n z-index: 2;\n color: #0056b3;\n text-decoration: none;\n background-color: #e9ecef;\n border-color: #dee2e6;\n}\n\n.page-link:focus {\n z-index: 2;\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.page-item:first-child .page-link {\n margin-left: 0;\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n}\n\n.page-item:last-child .page-link {\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n}\n\n.page-item.active .page-link {\n z-index: 1;\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.page-item.disabled .page-link {\n color: #6c757d;\n pointer-events: none;\n cursor: auto;\n background-color: #fff;\n border-color: #dee2e6;\n}\n\n.pagination-lg .page-link {\n padding: 0.75rem 1.5rem;\n font-size: 1.25rem;\n line-height: 1.5;\n}\n\n.pagination-lg .page-item:first-child .page-link {\n border-top-left-radius: 0.3rem;\n border-bottom-left-radius: 0.3rem;\n}\n\n.pagination-lg .page-item:last-child .page-link {\n border-top-right-radius: 0.3rem;\n border-bottom-right-radius: 0.3rem;\n}\n\n.pagination-sm .page-link {\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n line-height: 1.5;\n}\n\n.pagination-sm .page-item:first-child .page-link {\n border-top-left-radius: 0.2rem;\n border-bottom-left-radius: 0.2rem;\n}\n\n.pagination-sm .page-item:last-child .page-link {\n border-top-right-radius: 0.2rem;\n border-bottom-right-radius: 0.2rem;\n}\n\n.badge {\n display: inline-block;\n padding: 0.25em 0.4em;\n font-size: 75%;\n font-weight: 700;\n line-height: 1;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: 0.25rem;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .badge {\n transition: none;\n }\n}\n\na.badge:hover, a.badge:focus {\n text-decoration: none;\n}\n\n.badge:empty {\n display: none;\n}\n\n.btn .badge {\n position: relative;\n top: -1px;\n}\n\n.badge-pill {\n padding-right: 0.6em;\n padding-left: 0.6em;\n border-radius: 10rem;\n}\n\n.badge-primary {\n color: #fff;\n background-color: #007bff;\n}\n\na.badge-primary:hover, a.badge-primary:focus {\n color: #fff;\n background-color: #0062cc;\n}\n\na.badge-primary:focus, a.badge-primary.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);\n}\n\n.badge-secondary {\n color: #fff;\n background-color: #6c757d;\n}\n\na.badge-secondary:hover, a.badge-secondary:focus {\n color: #fff;\n background-color: #545b62;\n}\n\na.badge-secondary:focus, a.badge-secondary.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);\n}\n\n.badge-success {\n color: #fff;\n background-color: #28a745;\n}\n\na.badge-success:hover, a.badge-success:focus {\n color: #fff;\n background-color: #1e7e34;\n}\n\na.badge-success:focus, a.badge-success.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);\n}\n\n.badge-info {\n color: #fff;\n background-color: #17a2b8;\n}\n\na.badge-info:hover, a.badge-info:focus {\n color: #fff;\n background-color: #117a8b;\n}\n\na.badge-info:focus, a.badge-info.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);\n}\n\n.badge-warning {\n color: #212529;\n background-color: #ffc107;\n}\n\na.badge-warning:hover, a.badge-warning:focus {\n color: #212529;\n background-color: #d39e00;\n}\n\na.badge-warning:focus, a.badge-warning.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);\n}\n\n.badge-danger {\n color: #fff;\n background-color: #dc3545;\n}\n\na.badge-danger:hover, a.badge-danger:focus {\n color: #fff;\n background-color: #bd2130;\n}\n\na.badge-danger:focus, a.badge-danger.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);\n}\n\n.badge-light {\n color: #212529;\n background-color: #f8f9fa;\n}\n\na.badge-light:hover, a.badge-light:focus {\n color: #212529;\n background-color: #dae0e5;\n}\n\na.badge-light:focus, a.badge-light.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);\n}\n\n.badge-dark {\n color: #fff;\n background-color: #343a40;\n}\n\na.badge-dark:hover, a.badge-dark:focus {\n color: #fff;\n background-color: #1d2124;\n}\n\na.badge-dark:focus, a.badge-dark.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);\n}\n\n.jumbotron {\n padding: 2rem 1rem;\n margin-bottom: 2rem;\n background-color: #e9ecef;\n border-radius: 0.3rem;\n}\n\n@media (min-width: 576px) {\n .jumbotron {\n padding: 4rem 2rem;\n }\n}\n\n.jumbotron-fluid {\n padding-right: 0;\n padding-left: 0;\n border-radius: 0;\n}\n\n.alert {\n position: relative;\n padding: 0.75rem 1.25rem;\n margin-bottom: 1rem;\n border: 1px solid transparent;\n border-radius: 0.25rem;\n}\n\n.alert-heading {\n color: inherit;\n}\n\n.alert-link {\n font-weight: 700;\n}\n\n.alert-dismissible {\n padding-right: 4rem;\n}\n\n.alert-dismissible .close {\n position: absolute;\n top: 0;\n right: 0;\n padding: 0.75rem 1.25rem;\n color: inherit;\n}\n\n.alert-primary {\n color: #004085;\n background-color: #cce5ff;\n border-color: #b8daff;\n}\n\n.alert-primary hr {\n border-top-color: #9fcdff;\n}\n\n.alert-primary .alert-link {\n color: #002752;\n}\n\n.alert-secondary {\n color: #383d41;\n background-color: #e2e3e5;\n border-color: #d6d8db;\n}\n\n.alert-secondary hr {\n border-top-color: #c8cbcf;\n}\n\n.alert-secondary .alert-link {\n color: #202326;\n}\n\n.alert-success {\n color: #155724;\n background-color: #d4edda;\n border-color: #c3e6cb;\n}\n\n.alert-success hr {\n border-top-color: #b1dfbb;\n}\n\n.alert-success .alert-link {\n color: #0b2e13;\n}\n\n.alert-info {\n color: #0c5460;\n background-color: #d1ecf1;\n border-color: #bee5eb;\n}\n\n.alert-info hr {\n border-top-color: #abdde5;\n}\n\n.alert-info .alert-link {\n color: #062c33;\n}\n\n.alert-warning {\n color: #856404;\n background-color: #fff3cd;\n border-color: #ffeeba;\n}\n\n.alert-warning hr {\n border-top-color: #ffe8a1;\n}\n\n.alert-warning .alert-link {\n color: #533f03;\n}\n\n.alert-danger {\n color: #721c24;\n background-color: #f8d7da;\n border-color: #f5c6cb;\n}\n\n.alert-danger hr {\n border-top-color: #f1b0b7;\n}\n\n.alert-danger .alert-link {\n color: #491217;\n}\n\n.alert-light {\n color: #818182;\n background-color: #fefefe;\n border-color: #fdfdfe;\n}\n\n.alert-light hr {\n border-top-color: #ececf6;\n}\n\n.alert-light .alert-link {\n color: #686868;\n}\n\n.alert-dark {\n color: #1b1e21;\n background-color: #d6d8d9;\n border-color: #c6c8ca;\n}\n\n.alert-dark hr {\n border-top-color: #b9bbbe;\n}\n\n.alert-dark .alert-link {\n color: #040505;\n}\n\n@keyframes progress-bar-stripes {\n from {\n background-position: 1rem 0;\n }\n to {\n background-position: 0 0;\n }\n}\n\n.progress {\n display: flex;\n height: 1rem;\n overflow: hidden;\n font-size: 0.75rem;\n background-color: #e9ecef;\n border-radius: 0.25rem;\n}\n\n.progress-bar {\n display: flex;\n flex-direction: column;\n justify-content: center;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n background-color: #007bff;\n transition: width 0.6s ease;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .progress-bar {\n transition: none;\n }\n}\n\n.progress-bar-striped {\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-size: 1rem 1rem;\n}\n\n.progress-bar-animated {\n animation: progress-bar-stripes 1s linear infinite;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .progress-bar-animated {\n animation: none;\n }\n}\n\n.media {\n display: flex;\n align-items: flex-start;\n}\n\n.media-body {\n flex: 1;\n}\n\n.list-group {\n display: flex;\n flex-direction: column;\n padding-left: 0;\n margin-bottom: 0;\n}\n\n.list-group-item-action {\n width: 100%;\n color: #495057;\n text-align: inherit;\n}\n\n.list-group-item-action:hover, .list-group-item-action:focus {\n z-index: 1;\n color: #495057;\n text-decoration: none;\n background-color: #f8f9fa;\n}\n\n.list-group-item-action:active {\n color: #212529;\n background-color: #e9ecef;\n}\n\n.list-group-item {\n position: relative;\n display: block;\n padding: 0.75rem 1.25rem;\n margin-bottom: -1px;\n background-color: #fff;\n border: 1px solid rgba(0, 0, 0, 0.125);\n}\n\n.list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-top-right-radius: 0.25rem;\n}\n\n.list-group-item:last-child {\n margin-bottom: 0;\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n}\n\n.list-group-item.disabled, .list-group-item:disabled {\n color: #6c757d;\n pointer-events: none;\n background-color: #fff;\n}\n\n.list-group-item.active {\n z-index: 2;\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.list-group-horizontal {\n flex-direction: row;\n}\n\n.list-group-horizontal .list-group-item {\n margin-right: -1px;\n margin-bottom: 0;\n}\n\n.list-group-horizontal .list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n}\n\n.list-group-horizontal .list-group-item:last-child {\n margin-right: 0;\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n}\n\n@media (min-width: 576px) {\n .list-group-horizontal-sm {\n flex-direction: row;\n }\n .list-group-horizontal-sm .list-group-item {\n margin-right: -1px;\n margin-bottom: 0;\n }\n .list-group-horizontal-sm .list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n }\n .list-group-horizontal-sm .list-group-item:last-child {\n margin-right: 0;\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n }\n}\n\n@media (min-width: 768px) {\n .list-group-horizontal-md {\n flex-direction: row;\n }\n .list-group-horizontal-md .list-group-item {\n margin-right: -1px;\n margin-bottom: 0;\n }\n .list-group-horizontal-md .list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n }\n .list-group-horizontal-md .list-group-item:last-child {\n margin-right: 0;\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n }\n}\n\n@media (min-width: 992px) {\n .list-group-horizontal-lg {\n flex-direction: row;\n }\n .list-group-horizontal-lg .list-group-item {\n margin-right: -1px;\n margin-bottom: 0;\n }\n .list-group-horizontal-lg .list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n }\n .list-group-horizontal-lg .list-group-item:last-child {\n margin-right: 0;\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n }\n}\n\n@media (min-width: 1200px) {\n .list-group-horizontal-xl {\n flex-direction: row;\n }\n .list-group-horizontal-xl .list-group-item {\n margin-right: -1px;\n margin-bottom: 0;\n }\n .list-group-horizontal-xl .list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n }\n .list-group-horizontal-xl .list-group-item:last-child {\n margin-right: 0;\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n }\n}\n\n.list-group-flush .list-group-item {\n border-right: 0;\n border-left: 0;\n border-radius: 0;\n}\n\n.list-group-flush .list-group-item:last-child {\n margin-bottom: -1px;\n}\n\n.list-group-flush:first-child .list-group-item:first-child {\n border-top: 0;\n}\n\n.list-group-flush:last-child .list-group-item:last-child {\n margin-bottom: 0;\n border-bottom: 0;\n}\n\n.list-group-item-primary {\n color: #004085;\n background-color: #b8daff;\n}\n\n.list-group-item-primary.list-group-item-action:hover, .list-group-item-primary.list-group-item-action:focus {\n color: #004085;\n background-color: #9fcdff;\n}\n\n.list-group-item-primary.list-group-item-action.active {\n color: #fff;\n background-color: #004085;\n border-color: #004085;\n}\n\n.list-group-item-secondary {\n color: #383d41;\n background-color: #d6d8db;\n}\n\n.list-group-item-secondary.list-group-item-action:hover, .list-group-item-secondary.list-group-item-action:focus {\n color: #383d41;\n background-color: #c8cbcf;\n}\n\n.list-group-item-secondary.list-group-item-action.active {\n color: #fff;\n background-color: #383d41;\n border-color: #383d41;\n}\n\n.list-group-item-success {\n color: #155724;\n background-color: #c3e6cb;\n}\n\n.list-group-item-success.list-group-item-action:hover, .list-group-item-success.list-group-item-action:focus {\n color: #155724;\n background-color: #b1dfbb;\n}\n\n.list-group-item-success.list-group-item-action.active {\n color: #fff;\n background-color: #155724;\n border-color: #155724;\n}\n\n.list-group-item-info {\n color: #0c5460;\n background-color: #bee5eb;\n}\n\n.list-group-item-info.list-group-item-action:hover, .list-group-item-info.list-group-item-action:focus {\n color: #0c5460;\n background-color: #abdde5;\n}\n\n.list-group-item-info.list-group-item-action.active {\n color: #fff;\n background-color: #0c5460;\n border-color: #0c5460;\n}\n\n.list-group-item-warning {\n color: #856404;\n background-color: #ffeeba;\n}\n\n.list-group-item-warning.list-group-item-action:hover, .list-group-item-warning.list-group-item-action:focus {\n color: #856404;\n background-color: #ffe8a1;\n}\n\n.list-group-item-warning.list-group-item-action.active {\n color: #fff;\n background-color: #856404;\n border-color: #856404;\n}\n\n.list-group-item-danger {\n color: #721c24;\n background-color: #f5c6cb;\n}\n\n.list-group-item-danger.list-group-item-action:hover, .list-group-item-danger.list-group-item-action:focus {\n color: #721c24;\n background-color: #f1b0b7;\n}\n\n.list-group-item-danger.list-group-item-action.active {\n color: #fff;\n background-color: #721c24;\n border-color: #721c24;\n}\n\n.list-group-item-light {\n color: #818182;\n background-color: #fdfdfe;\n}\n\n.list-group-item-light.list-group-item-action:hover, .list-group-item-light.list-group-item-action:focus {\n color: #818182;\n background-color: #ececf6;\n}\n\n.list-group-item-light.list-group-item-action.active {\n color: #fff;\n background-color: #818182;\n border-color: #818182;\n}\n\n.list-group-item-dark {\n color: #1b1e21;\n background-color: #c6c8ca;\n}\n\n.list-group-item-dark.list-group-item-action:hover, .list-group-item-dark.list-group-item-action:focus {\n color: #1b1e21;\n background-color: #b9bbbe;\n}\n\n.list-group-item-dark.list-group-item-action.active {\n color: #fff;\n background-color: #1b1e21;\n border-color: #1b1e21;\n}\n\n.close {\n float: right;\n font-size: 1.5rem;\n font-weight: 700;\n line-height: 1;\n color: #000;\n text-shadow: 0 1px 0 #fff;\n opacity: .5;\n}\n\n.close:hover {\n color: #000;\n text-decoration: none;\n}\n\n.close:not(:disabled):not(.disabled):hover, .close:not(:disabled):not(.disabled):focus {\n opacity: .75;\n}\n\nbutton.close {\n padding: 0;\n background-color: transparent;\n border: 0;\n appearance: none;\n}\n\na.close.disabled {\n pointer-events: none;\n}\n\n.toast {\n max-width: 350px;\n overflow: hidden;\n font-size: 0.875rem;\n background-color: rgba(255, 255, 255, 0.85);\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.1);\n box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);\n backdrop-filter: blur(10px);\n opacity: 0;\n border-radius: 0.25rem;\n}\n\n.toast:not(:last-child) {\n margin-bottom: 0.75rem;\n}\n\n.toast.showing {\n opacity: 1;\n}\n\n.toast.show {\n display: block;\n opacity: 1;\n}\n\n.toast.hide {\n display: none;\n}\n\n.toast-header {\n display: flex;\n align-items: center;\n padding: 0.25rem 0.75rem;\n color: #6c757d;\n background-color: rgba(255, 255, 255, 0.85);\n background-clip: padding-box;\n border-bottom: 1px solid rgba(0, 0, 0, 0.05);\n}\n\n.toast-body {\n padding: 0.75rem;\n}\n\n.modal-open {\n overflow: hidden;\n}\n\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n\n.modal {\n position: fixed;\n top: 0;\n left: 0;\n z-index: 1050;\n display: none;\n width: 100%;\n height: 100%;\n overflow: hidden;\n outline: 0;\n}\n\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 0.5rem;\n pointer-events: none;\n}\n\n.modal.fade .modal-dialog {\n transition: transform 0.3s ease-out;\n transform: translate(0, -50px);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .modal.fade .modal-dialog {\n transition: none;\n }\n}\n\n.modal.show .modal-dialog {\n transform: none;\n}\n\n.modal-dialog-scrollable {\n display: flex;\n max-height: calc(100% - 1rem);\n}\n\n.modal-dialog-scrollable .modal-content {\n max-height: calc(100vh - 1rem);\n overflow: hidden;\n}\n\n.modal-dialog-scrollable .modal-header,\n.modal-dialog-scrollable .modal-footer {\n flex-shrink: 0;\n}\n\n.modal-dialog-scrollable .modal-body {\n overflow-y: auto;\n}\n\n.modal-dialog-centered {\n display: flex;\n align-items: center;\n min-height: calc(100% - 1rem);\n}\n\n.modal-dialog-centered::before {\n display: block;\n height: calc(100vh - 1rem);\n content: \"\";\n}\n\n.modal-dialog-centered.modal-dialog-scrollable {\n flex-direction: column;\n justify-content: center;\n height: 100%;\n}\n\n.modal-dialog-centered.modal-dialog-scrollable .modal-content {\n max-height: none;\n}\n\n.modal-dialog-centered.modal-dialog-scrollable::before {\n content: none;\n}\n\n.modal-content {\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n pointer-events: auto;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 0.3rem;\n outline: 0;\n}\n\n.modal-backdrop {\n position: fixed;\n top: 0;\n left: 0;\n z-index: 1040;\n width: 100vw;\n height: 100vh;\n background-color: #000;\n}\n\n.modal-backdrop.fade {\n opacity: 0;\n}\n\n.modal-backdrop.show {\n opacity: 0.5;\n}\n\n.modal-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n padding: 1rem 1rem;\n border-bottom: 1px solid #dee2e6;\n border-top-left-radius: 0.3rem;\n border-top-right-radius: 0.3rem;\n}\n\n.modal-header .close {\n padding: 1rem 1rem;\n margin: -1rem -1rem -1rem auto;\n}\n\n.modal-title {\n margin-bottom: 0;\n line-height: 1.5;\n}\n\n.modal-body {\n position: relative;\n flex: 1 1 auto;\n padding: 1rem;\n}\n\n.modal-footer {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n padding: 1rem;\n border-top: 1px solid #dee2e6;\n border-bottom-right-radius: 0.3rem;\n border-bottom-left-radius: 0.3rem;\n}\n\n.modal-footer > :not(:first-child) {\n margin-left: .25rem;\n}\n\n.modal-footer > :not(:last-child) {\n margin-right: .25rem;\n}\n\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n\n@media (min-width: 576px) {\n .modal-dialog {\n max-width: 500px;\n margin: 1.75rem auto;\n }\n .modal-dialog-scrollable {\n max-height: calc(100% - 3.5rem);\n }\n .modal-dialog-scrollable .modal-content {\n max-height: calc(100vh - 3.5rem);\n }\n .modal-dialog-centered {\n min-height: calc(100% - 3.5rem);\n }\n .modal-dialog-centered::before {\n height: calc(100vh - 3.5rem);\n }\n .modal-sm {\n max-width: 300px;\n }\n}\n\n@media (min-width: 992px) {\n .modal-lg,\n .modal-xl {\n max-width: 800px;\n }\n}\n\n@media (min-width: 1200px) {\n .modal-xl {\n max-width: 1140px;\n }\n}\n\n.tooltip {\n position: absolute;\n z-index: 1070;\n display: block;\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n font-style: normal;\n font-weight: 400;\n line-height: 1.5;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n white-space: normal;\n line-break: auto;\n font-size: 0.875rem;\n word-wrap: break-word;\n opacity: 0;\n}\n\n.tooltip.show {\n opacity: 0.9;\n}\n\n.tooltip .arrow {\n position: absolute;\n display: block;\n width: 0.8rem;\n height: 0.4rem;\n}\n\n.tooltip .arrow::before {\n position: absolute;\n content: \"\";\n border-color: transparent;\n border-style: solid;\n}\n\n.bs-tooltip-top, .bs-tooltip-auto[x-placement^=\"top\"] {\n padding: 0.4rem 0;\n}\n\n.bs-tooltip-top .arrow, .bs-tooltip-auto[x-placement^=\"top\"] .arrow {\n bottom: 0;\n}\n\n.bs-tooltip-top .arrow::before, .bs-tooltip-auto[x-placement^=\"top\"] .arrow::before {\n top: 0;\n border-width: 0.4rem 0.4rem 0;\n border-top-color: #000;\n}\n\n.bs-tooltip-right, .bs-tooltip-auto[x-placement^=\"right\"] {\n padding: 0 0.4rem;\n}\n\n.bs-tooltip-right .arrow, .bs-tooltip-auto[x-placement^=\"right\"] .arrow {\n left: 0;\n width: 0.4rem;\n height: 0.8rem;\n}\n\n.bs-tooltip-right .arrow::before, .bs-tooltip-auto[x-placement^=\"right\"] .arrow::before {\n right: 0;\n border-width: 0.4rem 0.4rem 0.4rem 0;\n border-right-color: #000;\n}\n\n.bs-tooltip-bottom, .bs-tooltip-auto[x-placement^=\"bottom\"] {\n padding: 0.4rem 0;\n}\n\n.bs-tooltip-bottom .arrow, .bs-tooltip-auto[x-placement^=\"bottom\"] .arrow {\n top: 0;\n}\n\n.bs-tooltip-bottom .arrow::before, .bs-tooltip-auto[x-placement^=\"bottom\"] .arrow::before {\n bottom: 0;\n border-width: 0 0.4rem 0.4rem;\n border-bottom-color: #000;\n}\n\n.bs-tooltip-left, .bs-tooltip-auto[x-placement^=\"left\"] {\n padding: 0 0.4rem;\n}\n\n.bs-tooltip-left .arrow, .bs-tooltip-auto[x-placement^=\"left\"] .arrow {\n right: 0;\n width: 0.4rem;\n height: 0.8rem;\n}\n\n.bs-tooltip-left .arrow::before, .bs-tooltip-auto[x-placement^=\"left\"] .arrow::before {\n left: 0;\n border-width: 0.4rem 0 0.4rem 0.4rem;\n border-left-color: #000;\n}\n\n.tooltip-inner {\n max-width: 200px;\n padding: 0.25rem 0.5rem;\n color: #fff;\n text-align: center;\n background-color: #000;\n border-radius: 0.25rem;\n}\n\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: 1060;\n display: block;\n max-width: 276px;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n font-style: normal;\n font-weight: 400;\n line-height: 1.5;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n white-space: normal;\n line-break: auto;\n font-size: 0.875rem;\n word-wrap: break-word;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 0.3rem;\n}\n\n.popover .arrow {\n position: absolute;\n display: block;\n width: 1rem;\n height: 0.5rem;\n margin: 0 0.3rem;\n}\n\n.popover .arrow::before, .popover .arrow::after {\n position: absolute;\n display: block;\n content: \"\";\n border-color: transparent;\n border-style: solid;\n}\n\n.bs-popover-top, .bs-popover-auto[x-placement^=\"top\"] {\n margin-bottom: 0.5rem;\n}\n\n.bs-popover-top > .arrow, .bs-popover-auto[x-placement^=\"top\"] > .arrow {\n bottom: calc((0.5rem + 1px) * -1);\n}\n\n.bs-popover-top > .arrow::before, .bs-popover-auto[x-placement^=\"top\"] > .arrow::before {\n bottom: 0;\n border-width: 0.5rem 0.5rem 0;\n border-top-color: rgba(0, 0, 0, 0.25);\n}\n\n.bs-popover-top > .arrow::after, .bs-popover-auto[x-placement^=\"top\"] > .arrow::after {\n bottom: 1px;\n border-width: 0.5rem 0.5rem 0;\n border-top-color: #fff;\n}\n\n.bs-popover-right, .bs-popover-auto[x-placement^=\"right\"] {\n margin-left: 0.5rem;\n}\n\n.bs-popover-right > .arrow, .bs-popover-auto[x-placement^=\"right\"] > .arrow {\n left: calc((0.5rem + 1px) * -1);\n width: 0.5rem;\n height: 1rem;\n margin: 0.3rem 0;\n}\n\n.bs-popover-right > .arrow::before, .bs-popover-auto[x-placement^=\"right\"] > .arrow::before {\n left: 0;\n border-width: 0.5rem 0.5rem 0.5rem 0;\n border-right-color: rgba(0, 0, 0, 0.25);\n}\n\n.bs-popover-right > .arrow::after, .bs-popover-auto[x-placement^=\"right\"] > .arrow::after {\n left: 1px;\n border-width: 0.5rem 0.5rem 0.5rem 0;\n border-right-color: #fff;\n}\n\n.bs-popover-bottom, .bs-popover-auto[x-placement^=\"bottom\"] {\n margin-top: 0.5rem;\n}\n\n.bs-popover-bottom > .arrow, .bs-popover-auto[x-placement^=\"bottom\"] > .arrow {\n top: calc((0.5rem + 1px) * -1);\n}\n\n.bs-popover-bottom > .arrow::before, .bs-popover-auto[x-placement^=\"bottom\"] > .arrow::before {\n top: 0;\n border-width: 0 0.5rem 0.5rem 0.5rem;\n border-bottom-color: rgba(0, 0, 0, 0.25);\n}\n\n.bs-popover-bottom > .arrow::after, .bs-popover-auto[x-placement^=\"bottom\"] > .arrow::after {\n top: 1px;\n border-width: 0 0.5rem 0.5rem 0.5rem;\n border-bottom-color: #fff;\n}\n\n.bs-popover-bottom .popover-header::before, .bs-popover-auto[x-placement^=\"bottom\"] .popover-header::before {\n position: absolute;\n top: 0;\n left: 50%;\n display: block;\n width: 1rem;\n margin-left: -0.5rem;\n content: \"\";\n border-bottom: 1px solid #f7f7f7;\n}\n\n.bs-popover-left, .bs-popover-auto[x-placement^=\"left\"] {\n margin-right: 0.5rem;\n}\n\n.bs-popover-left > .arrow, .bs-popover-auto[x-placement^=\"left\"] > .arrow {\n right: calc((0.5rem + 1px) * -1);\n width: 0.5rem;\n height: 1rem;\n margin: 0.3rem 0;\n}\n\n.bs-popover-left > .arrow::before, .bs-popover-auto[x-placement^=\"left\"] > .arrow::before {\n right: 0;\n border-width: 0.5rem 0 0.5rem 0.5rem;\n border-left-color: rgba(0, 0, 0, 0.25);\n}\n\n.bs-popover-left > .arrow::after, .bs-popover-auto[x-placement^=\"left\"] > .arrow::after {\n right: 1px;\n border-width: 0.5rem 0 0.5rem 0.5rem;\n border-left-color: #fff;\n}\n\n.popover-header {\n padding: 0.5rem 0.75rem;\n margin-bottom: 0;\n font-size: 1rem;\n background-color: #f7f7f7;\n border-bottom: 1px solid #ebebeb;\n border-top-left-radius: calc(0.3rem - 1px);\n border-top-right-radius: calc(0.3rem - 1px);\n}\n\n.popover-header:empty {\n display: none;\n}\n\n.popover-body {\n padding: 0.5rem 0.75rem;\n color: #212529;\n}\n\n.carousel {\n position: relative;\n}\n\n.carousel.pointer-event {\n touch-action: pan-y;\n}\n\n.carousel-inner {\n position: relative;\n width: 100%;\n overflow: hidden;\n}\n\n.carousel-inner::after {\n display: block;\n clear: both;\n content: \"\";\n}\n\n.carousel-item {\n position: relative;\n display: none;\n float: left;\n width: 100%;\n margin-right: -100%;\n backface-visibility: hidden;\n transition: transform 0.6s ease-in-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .carousel-item {\n transition: none;\n }\n}\n\n.carousel-item.active,\n.carousel-item-next,\n.carousel-item-prev {\n display: block;\n}\n\n.carousel-item-next:not(.carousel-item-left),\n.active.carousel-item-right {\n transform: translateX(100%);\n}\n\n.carousel-item-prev:not(.carousel-item-right),\n.active.carousel-item-left {\n transform: translateX(-100%);\n}\n\n.carousel-fade .carousel-item {\n opacity: 0;\n transition-property: opacity;\n transform: none;\n}\n\n.carousel-fade .carousel-item.active,\n.carousel-fade .carousel-item-next.carousel-item-left,\n.carousel-fade .carousel-item-prev.carousel-item-right {\n z-index: 1;\n opacity: 1;\n}\n\n.carousel-fade .active.carousel-item-left,\n.carousel-fade .active.carousel-item-right {\n z-index: 0;\n opacity: 0;\n transition: 0s 0.6s opacity;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .carousel-fade .active.carousel-item-left,\n .carousel-fade .active.carousel-item-right {\n transition: none;\n }\n}\n\n.carousel-control-prev,\n.carousel-control-next {\n position: absolute;\n top: 0;\n bottom: 0;\n z-index: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 15%;\n color: #fff;\n text-align: center;\n opacity: 0.5;\n transition: opacity 0.15s ease;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .carousel-control-prev,\n .carousel-control-next {\n transition: none;\n }\n}\n\n.carousel-control-prev:hover, .carousel-control-prev:focus,\n.carousel-control-next:hover,\n.carousel-control-next:focus {\n color: #fff;\n text-decoration: none;\n outline: 0;\n opacity: 0.9;\n}\n\n.carousel-control-prev {\n left: 0;\n}\n\n.carousel-control-next {\n right: 0;\n}\n\n.carousel-control-prev-icon,\n.carousel-control-next-icon {\n display: inline-block;\n width: 20px;\n height: 20px;\n background: no-repeat 50% / 100% 100%;\n}\n\n.carousel-control-prev-icon {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3e%3c/svg%3e\");\n}\n\n.carousel-control-next-icon {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3e%3c/svg%3e\");\n}\n\n.carousel-indicators {\n position: absolute;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 15;\n display: flex;\n justify-content: center;\n padding-left: 0;\n margin-right: 15%;\n margin-left: 15%;\n list-style: none;\n}\n\n.carousel-indicators li {\n box-sizing: content-box;\n flex: 0 1 auto;\n width: 30px;\n height: 3px;\n margin-right: 3px;\n margin-left: 3px;\n text-indent: -999px;\n cursor: pointer;\n background-color: #fff;\n background-clip: padding-box;\n border-top: 10px solid transparent;\n border-bottom: 10px solid transparent;\n opacity: .5;\n transition: opacity 0.6s ease;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .carousel-indicators li {\n transition: none;\n }\n}\n\n.carousel-indicators .active {\n opacity: 1;\n}\n\n.carousel-caption {\n position: absolute;\n right: 15%;\n bottom: 20px;\n left: 15%;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: #fff;\n text-align: center;\n}\n\n@keyframes spinner-border {\n to {\n transform: rotate(360deg);\n }\n}\n\n.spinner-border {\n display: inline-block;\n width: 2rem;\n height: 2rem;\n vertical-align: text-bottom;\n border: 0.25em solid currentColor;\n border-right-color: transparent;\n border-radius: 50%;\n animation: spinner-border .75s linear infinite;\n}\n\n.spinner-border-sm {\n width: 1rem;\n height: 1rem;\n border-width: 0.2em;\n}\n\n@keyframes spinner-grow {\n 0% {\n transform: scale(0);\n }\n 50% {\n opacity: 1;\n }\n}\n\n.spinner-grow {\n display: inline-block;\n width: 2rem;\n height: 2rem;\n vertical-align: text-bottom;\n background-color: currentColor;\n border-radius: 50%;\n opacity: 0;\n animation: spinner-grow .75s linear infinite;\n}\n\n.spinner-grow-sm {\n width: 1rem;\n height: 1rem;\n}\n\n.align-baseline {\n vertical-align: baseline !important;\n}\n\n.align-top {\n vertical-align: top !important;\n}\n\n.align-middle {\n vertical-align: middle !important;\n}\n\n.align-bottom {\n vertical-align: bottom !important;\n}\n\n.align-text-bottom {\n vertical-align: text-bottom !important;\n}\n\n.align-text-top {\n vertical-align: text-top !important;\n}\n\n.bg-primary {\n background-color: #007bff !important;\n}\n\na.bg-primary:hover, a.bg-primary:focus,\nbutton.bg-primary:hover,\nbutton.bg-primary:focus {\n background-color: #0062cc !important;\n}\n\n.bg-secondary {\n background-color: #6c757d !important;\n}\n\na.bg-secondary:hover, a.bg-secondary:focus,\nbutton.bg-secondary:hover,\nbutton.bg-secondary:focus {\n background-color: #545b62 !important;\n}\n\n.bg-success {\n background-color: #28a745 !important;\n}\n\na.bg-success:hover, a.bg-success:focus,\nbutton.bg-success:hover,\nbutton.bg-success:focus {\n background-color: #1e7e34 !important;\n}\n\n.bg-info {\n background-color: #17a2b8 !important;\n}\n\na.bg-info:hover, a.bg-info:focus,\nbutton.bg-info:hover,\nbutton.bg-info:focus {\n background-color: #117a8b !important;\n}\n\n.bg-warning {\n background-color: #ffc107 !important;\n}\n\na.bg-warning:hover, a.bg-warning:focus,\nbutton.bg-warning:hover,\nbutton.bg-warning:focus {\n background-color: #d39e00 !important;\n}\n\n.bg-danger {\n background-color: #dc3545 !important;\n}\n\na.bg-danger:hover, a.bg-danger:focus,\nbutton.bg-danger:hover,\nbutton.bg-danger:focus {\n background-color: #bd2130 !important;\n}\n\n.bg-light {\n background-color: #f8f9fa !important;\n}\n\na.bg-light:hover, a.bg-light:focus,\nbutton.bg-light:hover,\nbutton.bg-light:focus {\n background-color: #dae0e5 !important;\n}\n\n.bg-dark {\n background-color: #343a40 !important;\n}\n\na.bg-dark:hover, a.bg-dark:focus,\nbutton.bg-dark:hover,\nbutton.bg-dark:focus {\n background-color: #1d2124 !important;\n}\n\n.bg-white {\n background-color: #fff !important;\n}\n\n.bg-transparent {\n background-color: transparent !important;\n}\n\n.border {\n border: 1px solid #dee2e6 !important;\n}\n\n.border-top {\n border-top: 1px solid #dee2e6 !important;\n}\n\n.border-right {\n border-right: 1px solid #dee2e6 !important;\n}\n\n.border-bottom {\n border-bottom: 1px solid #dee2e6 !important;\n}\n\n.border-left {\n border-left: 1px solid #dee2e6 !important;\n}\n\n.border-0 {\n border: 0 !important;\n}\n\n.border-top-0 {\n border-top: 0 !important;\n}\n\n.border-right-0 {\n border-right: 0 !important;\n}\n\n.border-bottom-0 {\n border-bottom: 0 !important;\n}\n\n.border-left-0 {\n border-left: 0 !important;\n}\n\n.border-primary {\n border-color: #007bff !important;\n}\n\n.border-secondary {\n border-color: #6c757d !important;\n}\n\n.border-success {\n border-color: #28a745 !important;\n}\n\n.border-info {\n border-color: #17a2b8 !important;\n}\n\n.border-warning {\n border-color: #ffc107 !important;\n}\n\n.border-danger {\n border-color: #dc3545 !important;\n}\n\n.border-light {\n border-color: #f8f9fa !important;\n}\n\n.border-dark {\n border-color: #343a40 !important;\n}\n\n.border-white {\n border-color: #fff !important;\n}\n\n.rounded-sm {\n border-radius: 0.2rem !important;\n}\n\n.rounded {\n border-radius: 0.25rem !important;\n}\n\n.rounded-top {\n border-top-left-radius: 0.25rem !important;\n border-top-right-radius: 0.25rem !important;\n}\n\n.rounded-right {\n border-top-right-radius: 0.25rem !important;\n border-bottom-right-radius: 0.25rem !important;\n}\n\n.rounded-bottom {\n border-bottom-right-radius: 0.25rem !important;\n border-bottom-left-radius: 0.25rem !important;\n}\n\n.rounded-left {\n border-top-left-radius: 0.25rem !important;\n border-bottom-left-radius: 0.25rem !important;\n}\n\n.rounded-lg {\n border-radius: 0.3rem !important;\n}\n\n.rounded-circle {\n border-radius: 50% !important;\n}\n\n.rounded-pill {\n border-radius: 50rem !important;\n}\n\n.rounded-0 {\n border-radius: 0 !important;\n}\n\n.clearfix::after {\n display: block;\n clear: both;\n content: \"\";\n}\n\n.d-none {\n display: none !important;\n}\n\n.d-inline {\n display: inline !important;\n}\n\n.d-inline-block {\n display: inline-block !important;\n}\n\n.d-block {\n display: block !important;\n}\n\n.d-table {\n display: table !important;\n}\n\n.d-table-row {\n display: table-row !important;\n}\n\n.d-table-cell {\n display: table-cell !important;\n}\n\n.d-flex {\n display: flex !important;\n}\n\n.d-inline-flex {\n display: inline-flex !important;\n}\n\n@media (min-width: 576px) {\n .d-sm-none {\n display: none !important;\n }\n .d-sm-inline {\n display: inline !important;\n }\n .d-sm-inline-block {\n display: inline-block !important;\n }\n .d-sm-block {\n display: block !important;\n }\n .d-sm-table {\n display: table !important;\n }\n .d-sm-table-row {\n display: table-row !important;\n }\n .d-sm-table-cell {\n display: table-cell !important;\n }\n .d-sm-flex {\n display: flex !important;\n }\n .d-sm-inline-flex {\n display: inline-flex !important;\n }\n}\n\n@media (min-width: 768px) {\n .d-md-none {\n display: none !important;\n }\n .d-md-inline {\n display: inline !important;\n }\n .d-md-inline-block {\n display: inline-block !important;\n }\n .d-md-block {\n display: block !important;\n }\n .d-md-table {\n display: table !important;\n }\n .d-md-table-row {\n display: table-row !important;\n }\n .d-md-table-cell {\n display: table-cell !important;\n }\n .d-md-flex {\n display: flex !important;\n }\n .d-md-inline-flex {\n display: inline-flex !important;\n }\n}\n\n@media (min-width: 992px) {\n .d-lg-none {\n display: none !important;\n }\n .d-lg-inline {\n display: inline !important;\n }\n .d-lg-inline-block {\n display: inline-block !important;\n }\n .d-lg-block {\n display: block !important;\n }\n .d-lg-table {\n display: table !important;\n }\n .d-lg-table-row {\n display: table-row !important;\n }\n .d-lg-table-cell {\n display: table-cell !important;\n }\n .d-lg-flex {\n display: flex !important;\n }\n .d-lg-inline-flex {\n display: inline-flex !important;\n }\n}\n\n@media (min-width: 1200px) {\n .d-xl-none {\n display: none !important;\n }\n .d-xl-inline {\n display: inline !important;\n }\n .d-xl-inline-block {\n display: inline-block !important;\n }\n .d-xl-block {\n display: block !important;\n }\n .d-xl-table {\n display: table !important;\n }\n .d-xl-table-row {\n display: table-row !important;\n }\n .d-xl-table-cell {\n display: table-cell !important;\n }\n .d-xl-flex {\n display: flex !important;\n }\n .d-xl-inline-flex {\n display: inline-flex !important;\n }\n}\n\n@media print {\n .d-print-none {\n display: none !important;\n }\n .d-print-inline {\n display: inline !important;\n }\n .d-print-inline-block {\n display: inline-block !important;\n }\n .d-print-block {\n display: block !important;\n }\n .d-print-table {\n display: table !important;\n }\n .d-print-table-row {\n display: table-row !important;\n }\n .d-print-table-cell {\n display: table-cell !important;\n }\n .d-print-flex {\n display: flex !important;\n }\n .d-print-inline-flex {\n display: inline-flex !important;\n }\n}\n\n.embed-responsive {\n position: relative;\n display: block;\n width: 100%;\n padding: 0;\n overflow: hidden;\n}\n\n.embed-responsive::before {\n display: block;\n content: \"\";\n}\n\n.embed-responsive .embed-responsive-item,\n.embed-responsive iframe,\n.embed-responsive embed,\n.embed-responsive object,\n.embed-responsive video {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n width: 100%;\n height: 100%;\n border: 0;\n}\n\n.embed-responsive-21by9::before {\n padding-top: 42.857143%;\n}\n\n.embed-responsive-16by9::before {\n padding-top: 56.25%;\n}\n\n.embed-responsive-4by3::before {\n padding-top: 75%;\n}\n\n.embed-responsive-1by1::before {\n padding-top: 100%;\n}\n\n.flex-row {\n flex-direction: row !important;\n}\n\n.flex-column {\n flex-direction: column !important;\n}\n\n.flex-row-reverse {\n flex-direction: row-reverse !important;\n}\n\n.flex-column-reverse {\n flex-direction: column-reverse !important;\n}\n\n.flex-wrap {\n flex-wrap: wrap !important;\n}\n\n.flex-nowrap {\n flex-wrap: nowrap !important;\n}\n\n.flex-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n}\n\n.flex-fill {\n flex: 1 1 auto !important;\n}\n\n.flex-grow-0 {\n flex-grow: 0 !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.flex-shrink-0 {\n flex-shrink: 0 !important;\n}\n\n.flex-shrink-1 {\n flex-shrink: 1 !important;\n}\n\n.justify-content-start {\n justify-content: flex-start !important;\n}\n\n.justify-content-end {\n justify-content: flex-end !important;\n}\n\n.justify-content-center {\n justify-content: center !important;\n}\n\n.justify-content-between {\n justify-content: space-between !important;\n}\n\n.justify-content-around {\n justify-content: space-around !important;\n}\n\n.align-items-start {\n align-items: flex-start !important;\n}\n\n.align-items-end {\n align-items: flex-end !important;\n}\n\n.align-items-center {\n align-items: center !important;\n}\n\n.align-items-baseline {\n align-items: baseline !important;\n}\n\n.align-items-stretch {\n align-items: stretch !important;\n}\n\n.align-content-start {\n align-content: flex-start !important;\n}\n\n.align-content-end {\n align-content: flex-end !important;\n}\n\n.align-content-center {\n align-content: center !important;\n}\n\n.align-content-between {\n align-content: space-between !important;\n}\n\n.align-content-around {\n align-content: space-around !important;\n}\n\n.align-content-stretch {\n align-content: stretch !important;\n}\n\n.align-self-auto {\n align-self: auto !important;\n}\n\n.align-self-start {\n align-self: flex-start !important;\n}\n\n.align-self-end {\n align-self: flex-end !important;\n}\n\n.align-self-center {\n align-self: center !important;\n}\n\n.align-self-baseline {\n align-self: baseline !important;\n}\n\n.align-self-stretch {\n align-self: stretch !important;\n}\n\n@media (min-width: 576px) {\n .flex-sm-row {\n flex-direction: row !important;\n }\n .flex-sm-column {\n flex-direction: column !important;\n }\n .flex-sm-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-sm-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-sm-wrap {\n flex-wrap: wrap !important;\n }\n .flex-sm-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-sm-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .flex-sm-fill {\n flex: 1 1 auto !important;\n }\n .flex-sm-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-sm-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-sm-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-sm-shrink-1 {\n flex-shrink: 1 !important;\n }\n .justify-content-sm-start {\n justify-content: flex-start !important;\n }\n .justify-content-sm-end {\n justify-content: flex-end !important;\n }\n .justify-content-sm-center {\n justify-content: center !important;\n }\n .justify-content-sm-between {\n justify-content: space-between !important;\n }\n .justify-content-sm-around {\n justify-content: space-around !important;\n }\n .align-items-sm-start {\n align-items: flex-start !important;\n }\n .align-items-sm-end {\n align-items: flex-end !important;\n }\n .align-items-sm-center {\n align-items: center !important;\n }\n .align-items-sm-baseline {\n align-items: baseline !important;\n }\n .align-items-sm-stretch {\n align-items: stretch !important;\n }\n .align-content-sm-start {\n align-content: flex-start !important;\n }\n .align-content-sm-end {\n align-content: flex-end !important;\n }\n .align-content-sm-center {\n align-content: center !important;\n }\n .align-content-sm-between {\n align-content: space-between !important;\n }\n .align-content-sm-around {\n align-content: space-around !important;\n }\n .align-content-sm-stretch {\n align-content: stretch !important;\n }\n .align-self-sm-auto {\n align-self: auto !important;\n }\n .align-self-sm-start {\n align-self: flex-start !important;\n }\n .align-self-sm-end {\n align-self: flex-end !important;\n }\n .align-self-sm-center {\n align-self: center !important;\n }\n .align-self-sm-baseline {\n align-self: baseline !important;\n }\n .align-self-sm-stretch {\n align-self: stretch !important;\n }\n}\n\n@media (min-width: 768px) {\n .flex-md-row {\n flex-direction: row !important;\n }\n .flex-md-column {\n flex-direction: column !important;\n }\n .flex-md-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-md-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-md-wrap {\n flex-wrap: wrap !important;\n }\n .flex-md-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-md-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .flex-md-fill {\n flex: 1 1 auto !important;\n }\n .flex-md-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-md-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-md-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-md-shrink-1 {\n flex-shrink: 1 !important;\n }\n .justify-content-md-start {\n justify-content: flex-start !important;\n }\n .justify-content-md-end {\n justify-content: flex-end !important;\n }\n .justify-content-md-center {\n justify-content: center !important;\n }\n .justify-content-md-between {\n justify-content: space-between !important;\n }\n .justify-content-md-around {\n justify-content: space-around !important;\n }\n .align-items-md-start {\n align-items: flex-start !important;\n }\n .align-items-md-end {\n align-items: flex-end !important;\n }\n .align-items-md-center {\n align-items: center !important;\n }\n .align-items-md-baseline {\n align-items: baseline !important;\n }\n .align-items-md-stretch {\n align-items: stretch !important;\n }\n .align-content-md-start {\n align-content: flex-start !important;\n }\n .align-content-md-end {\n align-content: flex-end !important;\n }\n .align-content-md-center {\n align-content: center !important;\n }\n .align-content-md-between {\n align-content: space-between !important;\n }\n .align-content-md-around {\n align-content: space-around !important;\n }\n .align-content-md-stretch {\n align-content: stretch !important;\n }\n .align-self-md-auto {\n align-self: auto !important;\n }\n .align-self-md-start {\n align-self: flex-start !important;\n }\n .align-self-md-end {\n align-self: flex-end !important;\n }\n .align-self-md-center {\n align-self: center !important;\n }\n .align-self-md-baseline {\n align-self: baseline !important;\n }\n .align-self-md-stretch {\n align-self: stretch !important;\n }\n}\n\n@media (min-width: 992px) {\n .flex-lg-row {\n flex-direction: row !important;\n }\n .flex-lg-column {\n flex-direction: column !important;\n }\n .flex-lg-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-lg-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-lg-wrap {\n flex-wrap: wrap !important;\n }\n .flex-lg-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-lg-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .flex-lg-fill {\n flex: 1 1 auto !important;\n }\n .flex-lg-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-lg-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-lg-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-lg-shrink-1 {\n flex-shrink: 1 !important;\n }\n .justify-content-lg-start {\n justify-content: flex-start !important;\n }\n .justify-content-lg-end {\n justify-content: flex-end !important;\n }\n .justify-content-lg-center {\n justify-content: center !important;\n }\n .justify-content-lg-between {\n justify-content: space-between !important;\n }\n .justify-content-lg-around {\n justify-content: space-around !important;\n }\n .align-items-lg-start {\n align-items: flex-start !important;\n }\n .align-items-lg-end {\n align-items: flex-end !important;\n }\n .align-items-lg-center {\n align-items: center !important;\n }\n .align-items-lg-baseline {\n align-items: baseline !important;\n }\n .align-items-lg-stretch {\n align-items: stretch !important;\n }\n .align-content-lg-start {\n align-content: flex-start !important;\n }\n .align-content-lg-end {\n align-content: flex-end !important;\n }\n .align-content-lg-center {\n align-content: center !important;\n }\n .align-content-lg-between {\n align-content: space-between !important;\n }\n .align-content-lg-around {\n align-content: space-around !important;\n }\n .align-content-lg-stretch {\n align-content: stretch !important;\n }\n .align-self-lg-auto {\n align-self: auto !important;\n }\n .align-self-lg-start {\n align-self: flex-start !important;\n }\n .align-self-lg-end {\n align-self: flex-end !important;\n }\n .align-self-lg-center {\n align-self: center !important;\n }\n .align-self-lg-baseline {\n align-self: baseline !important;\n }\n .align-self-lg-stretch {\n align-self: stretch !important;\n }\n}\n\n@media (min-width: 1200px) {\n .flex-xl-row {\n flex-direction: row !important;\n }\n .flex-xl-column {\n flex-direction: column !important;\n }\n .flex-xl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .flex-xl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .justify-content-xl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xl-center {\n justify-content: center !important;\n }\n .justify-content-xl-between {\n justify-content: space-between !important;\n }\n .justify-content-xl-around {\n justify-content: space-around !important;\n }\n .align-items-xl-start {\n align-items: flex-start !important;\n }\n .align-items-xl-end {\n align-items: flex-end !important;\n }\n .align-items-xl-center {\n align-items: center !important;\n }\n .align-items-xl-baseline {\n align-items: baseline !important;\n }\n .align-items-xl-stretch {\n align-items: stretch !important;\n }\n .align-content-xl-start {\n align-content: flex-start !important;\n }\n .align-content-xl-end {\n align-content: flex-end !important;\n }\n .align-content-xl-center {\n align-content: center !important;\n }\n .align-content-xl-between {\n align-content: space-between !important;\n }\n .align-content-xl-around {\n align-content: space-around !important;\n }\n .align-content-xl-stretch {\n align-content: stretch !important;\n }\n .align-self-xl-auto {\n align-self: auto !important;\n }\n .align-self-xl-start {\n align-self: flex-start !important;\n }\n .align-self-xl-end {\n align-self: flex-end !important;\n }\n .align-self-xl-center {\n align-self: center !important;\n }\n .align-self-xl-baseline {\n align-self: baseline !important;\n }\n .align-self-xl-stretch {\n align-self: stretch !important;\n }\n}\n\n.float-left {\n float: left !important;\n}\n\n.float-right {\n float: right !important;\n}\n\n.float-none {\n float: none !important;\n}\n\n@media (min-width: 576px) {\n .float-sm-left {\n float: left !important;\n }\n .float-sm-right {\n float: right !important;\n }\n .float-sm-none {\n float: none !important;\n }\n}\n\n@media (min-width: 768px) {\n .float-md-left {\n float: left !important;\n }\n .float-md-right {\n float: right !important;\n }\n .float-md-none {\n float: none !important;\n }\n}\n\n@media (min-width: 992px) {\n .float-lg-left {\n float: left !important;\n }\n .float-lg-right {\n float: right !important;\n }\n .float-lg-none {\n float: none !important;\n }\n}\n\n@media (min-width: 1200px) {\n .float-xl-left {\n float: left !important;\n }\n .float-xl-right {\n float: right !important;\n }\n .float-xl-none {\n float: none !important;\n }\n}\n\n.overflow-auto {\n overflow: auto !important;\n}\n\n.overflow-hidden {\n overflow: hidden !important;\n}\n\n.position-static {\n position: static !important;\n}\n\n.position-relative {\n position: relative !important;\n}\n\n.position-absolute {\n position: absolute !important;\n}\n\n.position-fixed {\n position: fixed !important;\n}\n\n.position-sticky {\n position: sticky !important;\n}\n\n.fixed-top {\n position: fixed;\n top: 0;\n right: 0;\n left: 0;\n z-index: 1030;\n}\n\n.fixed-bottom {\n position: fixed;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1030;\n}\n\n@supports (position: sticky) {\n .sticky-top {\n position: sticky;\n top: 0;\n z-index: 1020;\n }\n}\n\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n\n.sr-only-focusable:active, .sr-only-focusable:focus {\n position: static;\n width: auto;\n height: auto;\n overflow: visible;\n clip: auto;\n white-space: normal;\n}\n\n.shadow-sm {\n box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important;\n}\n\n.shadow {\n box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;\n}\n\n.shadow-lg {\n box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175) !important;\n}\n\n.shadow-none {\n box-shadow: none !important;\n}\n\n.w-25 {\n width: 25% !important;\n}\n\n.w-50 {\n width: 50% !important;\n}\n\n.w-75 {\n width: 75% !important;\n}\n\n.w-100 {\n width: 100% !important;\n}\n\n.w-auto {\n width: auto !important;\n}\n\n.h-25 {\n height: 25% !important;\n}\n\n.h-50 {\n height: 50% !important;\n}\n\n.h-75 {\n height: 75% !important;\n}\n\n.h-100 {\n height: 100% !important;\n}\n\n.h-auto {\n height: auto !important;\n}\n\n.mw-100 {\n max-width: 100% !important;\n}\n\n.mh-100 {\n max-height: 100% !important;\n}\n\n.min-vw-100 {\n min-width: 100vw !important;\n}\n\n.min-vh-100 {\n min-height: 100vh !important;\n}\n\n.vw-100 {\n width: 100vw !important;\n}\n\n.vh-100 {\n height: 100vh !important;\n}\n\n.stretched-link::after {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1;\n pointer-events: auto;\n content: \"\";\n background-color: rgba(0, 0, 0, 0);\n}\n\n.m-0 {\n margin: 0 !important;\n}\n\n.mt-0,\n.my-0 {\n margin-top: 0 !important;\n}\n\n.mr-0,\n.mx-0 {\n margin-right: 0 !important;\n}\n\n.mb-0,\n.my-0 {\n margin-bottom: 0 !important;\n}\n\n.ml-0,\n.mx-0 {\n margin-left: 0 !important;\n}\n\n.m-1 {\n margin: 0.25rem !important;\n}\n\n.mt-1,\n.my-1 {\n margin-top: 0.25rem !important;\n}\n\n.mr-1,\n.mx-1 {\n margin-right: 0.25rem !important;\n}\n\n.mb-1,\n.my-1 {\n margin-bottom: 0.25rem !important;\n}\n\n.ml-1,\n.mx-1 {\n margin-left: 0.25rem !important;\n}\n\n.m-2 {\n margin: 0.5rem !important;\n}\n\n.mt-2,\n.my-2 {\n margin-top: 0.5rem !important;\n}\n\n.mr-2,\n.mx-2 {\n margin-right: 0.5rem !important;\n}\n\n.mb-2,\n.my-2 {\n margin-bottom: 0.5rem !important;\n}\n\n.ml-2,\n.mx-2 {\n margin-left: 0.5rem !important;\n}\n\n.m-3 {\n margin: 1rem !important;\n}\n\n.mt-3,\n.my-3 {\n margin-top: 1rem !important;\n}\n\n.mr-3,\n.mx-3 {\n margin-right: 1rem !important;\n}\n\n.mb-3,\n.my-3 {\n margin-bottom: 1rem !important;\n}\n\n.ml-3,\n.mx-3 {\n margin-left: 1rem !important;\n}\n\n.m-4 {\n margin: 1.5rem !important;\n}\n\n.mt-4,\n.my-4 {\n margin-top: 1.5rem !important;\n}\n\n.mr-4,\n.mx-4 {\n margin-right: 1.5rem !important;\n}\n\n.mb-4,\n.my-4 {\n margin-bottom: 1.5rem !important;\n}\n\n.ml-4,\n.mx-4 {\n margin-left: 1.5rem !important;\n}\n\n.m-5 {\n margin: 3rem !important;\n}\n\n.mt-5,\n.my-5 {\n margin-top: 3rem !important;\n}\n\n.mr-5,\n.mx-5 {\n margin-right: 3rem !important;\n}\n\n.mb-5,\n.my-5 {\n margin-bottom: 3rem !important;\n}\n\n.ml-5,\n.mx-5 {\n margin-left: 3rem !important;\n}\n\n.p-0 {\n padding: 0 !important;\n}\n\n.pt-0,\n.py-0 {\n padding-top: 0 !important;\n}\n\n.pr-0,\n.px-0 {\n padding-right: 0 !important;\n}\n\n.pb-0,\n.py-0 {\n padding-bottom: 0 !important;\n}\n\n.pl-0,\n.px-0 {\n padding-left: 0 !important;\n}\n\n.p-1 {\n padding: 0.25rem !important;\n}\n\n.pt-1,\n.py-1 {\n padding-top: 0.25rem !important;\n}\n\n.pr-1,\n.px-1 {\n padding-right: 0.25rem !important;\n}\n\n.pb-1,\n.py-1 {\n padding-bottom: 0.25rem !important;\n}\n\n.pl-1,\n.px-1 {\n padding-left: 0.25rem !important;\n}\n\n.p-2 {\n padding: 0.5rem !important;\n}\n\n.pt-2,\n.py-2 {\n padding-top: 0.5rem !important;\n}\n\n.pr-2,\n.px-2 {\n padding-right: 0.5rem !important;\n}\n\n.pb-2,\n.py-2 {\n padding-bottom: 0.5rem !important;\n}\n\n.pl-2,\n.px-2 {\n padding-left: 0.5rem !important;\n}\n\n.p-3 {\n padding: 1rem !important;\n}\n\n.pt-3,\n.py-3 {\n padding-top: 1rem !important;\n}\n\n.pr-3,\n.px-3 {\n padding-right: 1rem !important;\n}\n\n.pb-3,\n.py-3 {\n padding-bottom: 1rem !important;\n}\n\n.pl-3,\n.px-3 {\n padding-left: 1rem !important;\n}\n\n.p-4 {\n padding: 1.5rem !important;\n}\n\n.pt-4,\n.py-4 {\n padding-top: 1.5rem !important;\n}\n\n.pr-4,\n.px-4 {\n padding-right: 1.5rem !important;\n}\n\n.pb-4,\n.py-4 {\n padding-bottom: 1.5rem !important;\n}\n\n.pl-4,\n.px-4 {\n padding-left: 1.5rem !important;\n}\n\n.p-5 {\n padding: 3rem !important;\n}\n\n.pt-5,\n.py-5 {\n padding-top: 3rem !important;\n}\n\n.pr-5,\n.px-5 {\n padding-right: 3rem !important;\n}\n\n.pb-5,\n.py-5 {\n padding-bottom: 3rem !important;\n}\n\n.pl-5,\n.px-5 {\n padding-left: 3rem !important;\n}\n\n.m-n1 {\n margin: -0.25rem !important;\n}\n\n.mt-n1,\n.my-n1 {\n margin-top: -0.25rem !important;\n}\n\n.mr-n1,\n.mx-n1 {\n margin-right: -0.25rem !important;\n}\n\n.mb-n1,\n.my-n1 {\n margin-bottom: -0.25rem !important;\n}\n\n.ml-n1,\n.mx-n1 {\n margin-left: -0.25rem !important;\n}\n\n.m-n2 {\n margin: -0.5rem !important;\n}\n\n.mt-n2,\n.my-n2 {\n margin-top: -0.5rem !important;\n}\n\n.mr-n2,\n.mx-n2 {\n margin-right: -0.5rem !important;\n}\n\n.mb-n2,\n.my-n2 {\n margin-bottom: -0.5rem !important;\n}\n\n.ml-n2,\n.mx-n2 {\n margin-left: -0.5rem !important;\n}\n\n.m-n3 {\n margin: -1rem !important;\n}\n\n.mt-n3,\n.my-n3 {\n margin-top: -1rem !important;\n}\n\n.mr-n3,\n.mx-n3 {\n margin-right: -1rem !important;\n}\n\n.mb-n3,\n.my-n3 {\n margin-bottom: -1rem !important;\n}\n\n.ml-n3,\n.mx-n3 {\n margin-left: -1rem !important;\n}\n\n.m-n4 {\n margin: -1.5rem !important;\n}\n\n.mt-n4,\n.my-n4 {\n margin-top: -1.5rem !important;\n}\n\n.mr-n4,\n.mx-n4 {\n margin-right: -1.5rem !important;\n}\n\n.mb-n4,\n.my-n4 {\n margin-bottom: -1.5rem !important;\n}\n\n.ml-n4,\n.mx-n4 {\n margin-left: -1.5rem !important;\n}\n\n.m-n5 {\n margin: -3rem !important;\n}\n\n.mt-n5,\n.my-n5 {\n margin-top: -3rem !important;\n}\n\n.mr-n5,\n.mx-n5 {\n margin-right: -3rem !important;\n}\n\n.mb-n5,\n.my-n5 {\n margin-bottom: -3rem !important;\n}\n\n.ml-n5,\n.mx-n5 {\n margin-left: -3rem !important;\n}\n\n.m-auto {\n margin: auto !important;\n}\n\n.mt-auto,\n.my-auto {\n margin-top: auto !important;\n}\n\n.mr-auto,\n.mx-auto {\n margin-right: auto !important;\n}\n\n.mb-auto,\n.my-auto {\n margin-bottom: auto !important;\n}\n\n.ml-auto,\n.mx-auto {\n margin-left: auto !important;\n}\n\n@media (min-width: 576px) {\n .m-sm-0 {\n margin: 0 !important;\n }\n .mt-sm-0,\n .my-sm-0 {\n margin-top: 0 !important;\n }\n .mr-sm-0,\n .mx-sm-0 {\n margin-right: 0 !important;\n }\n .mb-sm-0,\n .my-sm-0 {\n margin-bottom: 0 !important;\n }\n .ml-sm-0,\n .mx-sm-0 {\n margin-left: 0 !important;\n }\n .m-sm-1 {\n margin: 0.25rem !important;\n }\n .mt-sm-1,\n .my-sm-1 {\n margin-top: 0.25rem !important;\n }\n .mr-sm-1,\n .mx-sm-1 {\n margin-right: 0.25rem !important;\n }\n .mb-sm-1,\n .my-sm-1 {\n margin-bottom: 0.25rem !important;\n }\n .ml-sm-1,\n .mx-sm-1 {\n margin-left: 0.25rem !important;\n }\n .m-sm-2 {\n margin: 0.5rem !important;\n }\n .mt-sm-2,\n .my-sm-2 {\n margin-top: 0.5rem !important;\n }\n .mr-sm-2,\n .mx-sm-2 {\n margin-right: 0.5rem !important;\n }\n .mb-sm-2,\n .my-sm-2 {\n margin-bottom: 0.5rem !important;\n }\n .ml-sm-2,\n .mx-sm-2 {\n margin-left: 0.5rem !important;\n }\n .m-sm-3 {\n margin: 1rem !important;\n }\n .mt-sm-3,\n .my-sm-3 {\n margin-top: 1rem !important;\n }\n .mr-sm-3,\n .mx-sm-3 {\n margin-right: 1rem !important;\n }\n .mb-sm-3,\n .my-sm-3 {\n margin-bottom: 1rem !important;\n }\n .ml-sm-3,\n .mx-sm-3 {\n margin-left: 1rem !important;\n }\n .m-sm-4 {\n margin: 1.5rem !important;\n }\n .mt-sm-4,\n .my-sm-4 {\n margin-top: 1.5rem !important;\n }\n .mr-sm-4,\n .mx-sm-4 {\n margin-right: 1.5rem !important;\n }\n .mb-sm-4,\n .my-sm-4 {\n margin-bottom: 1.5rem !important;\n }\n .ml-sm-4,\n .mx-sm-4 {\n margin-left: 1.5rem !important;\n }\n .m-sm-5 {\n margin: 3rem !important;\n }\n .mt-sm-5,\n .my-sm-5 {\n margin-top: 3rem !important;\n }\n .mr-sm-5,\n .mx-sm-5 {\n margin-right: 3rem !important;\n }\n .mb-sm-5,\n .my-sm-5 {\n margin-bottom: 3rem !important;\n }\n .ml-sm-5,\n .mx-sm-5 {\n margin-left: 3rem !important;\n }\n .p-sm-0 {\n padding: 0 !important;\n }\n .pt-sm-0,\n .py-sm-0 {\n padding-top: 0 !important;\n }\n .pr-sm-0,\n .px-sm-0 {\n padding-right: 0 !important;\n }\n .pb-sm-0,\n .py-sm-0 {\n padding-bottom: 0 !important;\n }\n .pl-sm-0,\n .px-sm-0 {\n padding-left: 0 !important;\n }\n .p-sm-1 {\n padding: 0.25rem !important;\n }\n .pt-sm-1,\n .py-sm-1 {\n padding-top: 0.25rem !important;\n }\n .pr-sm-1,\n .px-sm-1 {\n padding-right: 0.25rem !important;\n }\n .pb-sm-1,\n .py-sm-1 {\n padding-bottom: 0.25rem !important;\n }\n .pl-sm-1,\n .px-sm-1 {\n padding-left: 0.25rem !important;\n }\n .p-sm-2 {\n padding: 0.5rem !important;\n }\n .pt-sm-2,\n .py-sm-2 {\n padding-top: 0.5rem !important;\n }\n .pr-sm-2,\n .px-sm-2 {\n padding-right: 0.5rem !important;\n }\n .pb-sm-2,\n .py-sm-2 {\n padding-bottom: 0.5rem !important;\n }\n .pl-sm-2,\n .px-sm-2 {\n padding-left: 0.5rem !important;\n }\n .p-sm-3 {\n padding: 1rem !important;\n }\n .pt-sm-3,\n .py-sm-3 {\n padding-top: 1rem !important;\n }\n .pr-sm-3,\n .px-sm-3 {\n padding-right: 1rem !important;\n }\n .pb-sm-3,\n .py-sm-3 {\n padding-bottom: 1rem !important;\n }\n .pl-sm-3,\n .px-sm-3 {\n padding-left: 1rem !important;\n }\n .p-sm-4 {\n padding: 1.5rem !important;\n }\n .pt-sm-4,\n .py-sm-4 {\n padding-top: 1.5rem !important;\n }\n .pr-sm-4,\n .px-sm-4 {\n padding-right: 1.5rem !important;\n }\n .pb-sm-4,\n .py-sm-4 {\n padding-bottom: 1.5rem !important;\n }\n .pl-sm-4,\n .px-sm-4 {\n padding-left: 1.5rem !important;\n }\n .p-sm-5 {\n padding: 3rem !important;\n }\n .pt-sm-5,\n .py-sm-5 {\n padding-top: 3rem !important;\n }\n .pr-sm-5,\n .px-sm-5 {\n padding-right: 3rem !important;\n }\n .pb-sm-5,\n .py-sm-5 {\n padding-bottom: 3rem !important;\n }\n .pl-sm-5,\n .px-sm-5 {\n padding-left: 3rem !important;\n }\n .m-sm-n1 {\n margin: -0.25rem !important;\n }\n .mt-sm-n1,\n .my-sm-n1 {\n margin-top: -0.25rem !important;\n }\n .mr-sm-n1,\n .mx-sm-n1 {\n margin-right: -0.25rem !important;\n }\n .mb-sm-n1,\n .my-sm-n1 {\n margin-bottom: -0.25rem !important;\n }\n .ml-sm-n1,\n .mx-sm-n1 {\n margin-left: -0.25rem !important;\n }\n .m-sm-n2 {\n margin: -0.5rem !important;\n }\n .mt-sm-n2,\n .my-sm-n2 {\n margin-top: -0.5rem !important;\n }\n .mr-sm-n2,\n .mx-sm-n2 {\n margin-right: -0.5rem !important;\n }\n .mb-sm-n2,\n .my-sm-n2 {\n margin-bottom: -0.5rem !important;\n }\n .ml-sm-n2,\n .mx-sm-n2 {\n margin-left: -0.5rem !important;\n }\n .m-sm-n3 {\n margin: -1rem !important;\n }\n .mt-sm-n3,\n .my-sm-n3 {\n margin-top: -1rem !important;\n }\n .mr-sm-n3,\n .mx-sm-n3 {\n margin-right: -1rem !important;\n }\n .mb-sm-n3,\n .my-sm-n3 {\n margin-bottom: -1rem !important;\n }\n .ml-sm-n3,\n .mx-sm-n3 {\n margin-left: -1rem !important;\n }\n .m-sm-n4 {\n margin: -1.5rem !important;\n }\n .mt-sm-n4,\n .my-sm-n4 {\n margin-top: -1.5rem !important;\n }\n .mr-sm-n4,\n .mx-sm-n4 {\n margin-right: -1.5rem !important;\n }\n .mb-sm-n4,\n .my-sm-n4 {\n margin-bottom: -1.5rem !important;\n }\n .ml-sm-n4,\n .mx-sm-n4 {\n margin-left: -1.5rem !important;\n }\n .m-sm-n5 {\n margin: -3rem !important;\n }\n .mt-sm-n5,\n .my-sm-n5 {\n margin-top: -3rem !important;\n }\n .mr-sm-n5,\n .mx-sm-n5 {\n margin-right: -3rem !important;\n }\n .mb-sm-n5,\n .my-sm-n5 {\n margin-bottom: -3rem !important;\n }\n .ml-sm-n5,\n .mx-sm-n5 {\n margin-left: -3rem !important;\n }\n .m-sm-auto {\n margin: auto !important;\n }\n .mt-sm-auto,\n .my-sm-auto {\n margin-top: auto !important;\n }\n .mr-sm-auto,\n .mx-sm-auto {\n margin-right: auto !important;\n }\n .mb-sm-auto,\n .my-sm-auto {\n margin-bottom: auto !important;\n }\n .ml-sm-auto,\n .mx-sm-auto {\n margin-left: auto !important;\n }\n}\n\n@media (min-width: 768px) {\n .m-md-0 {\n margin: 0 !important;\n }\n .mt-md-0,\n .my-md-0 {\n margin-top: 0 !important;\n }\n .mr-md-0,\n .mx-md-0 {\n margin-right: 0 !important;\n }\n .mb-md-0,\n .my-md-0 {\n margin-bottom: 0 !important;\n }\n .ml-md-0,\n .mx-md-0 {\n margin-left: 0 !important;\n }\n .m-md-1 {\n margin: 0.25rem !important;\n }\n .mt-md-1,\n .my-md-1 {\n margin-top: 0.25rem !important;\n }\n .mr-md-1,\n .mx-md-1 {\n margin-right: 0.25rem !important;\n }\n .mb-md-1,\n .my-md-1 {\n margin-bottom: 0.25rem !important;\n }\n .ml-md-1,\n .mx-md-1 {\n margin-left: 0.25rem !important;\n }\n .m-md-2 {\n margin: 0.5rem !important;\n }\n .mt-md-2,\n .my-md-2 {\n margin-top: 0.5rem !important;\n }\n .mr-md-2,\n .mx-md-2 {\n margin-right: 0.5rem !important;\n }\n .mb-md-2,\n .my-md-2 {\n margin-bottom: 0.5rem !important;\n }\n .ml-md-2,\n .mx-md-2 {\n margin-left: 0.5rem !important;\n }\n .m-md-3 {\n margin: 1rem !important;\n }\n .mt-md-3,\n .my-md-3 {\n margin-top: 1rem !important;\n }\n .mr-md-3,\n .mx-md-3 {\n margin-right: 1rem !important;\n }\n .mb-md-3,\n .my-md-3 {\n margin-bottom: 1rem !important;\n }\n .ml-md-3,\n .mx-md-3 {\n margin-left: 1rem !important;\n }\n .m-md-4 {\n margin: 1.5rem !important;\n }\n .mt-md-4,\n .my-md-4 {\n margin-top: 1.5rem !important;\n }\n .mr-md-4,\n .mx-md-4 {\n margin-right: 1.5rem !important;\n }\n .mb-md-4,\n .my-md-4 {\n margin-bottom: 1.5rem !important;\n }\n .ml-md-4,\n .mx-md-4 {\n margin-left: 1.5rem !important;\n }\n .m-md-5 {\n margin: 3rem !important;\n }\n .mt-md-5,\n .my-md-5 {\n margin-top: 3rem !important;\n }\n .mr-md-5,\n .mx-md-5 {\n margin-right: 3rem !important;\n }\n .mb-md-5,\n .my-md-5 {\n margin-bottom: 3rem !important;\n }\n .ml-md-5,\n .mx-md-5 {\n margin-left: 3rem !important;\n }\n .p-md-0 {\n padding: 0 !important;\n }\n .pt-md-0,\n .py-md-0 {\n padding-top: 0 !important;\n }\n .pr-md-0,\n .px-md-0 {\n padding-right: 0 !important;\n }\n .pb-md-0,\n .py-md-0 {\n padding-bottom: 0 !important;\n }\n .pl-md-0,\n .px-md-0 {\n padding-left: 0 !important;\n }\n .p-md-1 {\n padding: 0.25rem !important;\n }\n .pt-md-1,\n .py-md-1 {\n padding-top: 0.25rem !important;\n }\n .pr-md-1,\n .px-md-1 {\n padding-right: 0.25rem !important;\n }\n .pb-md-1,\n .py-md-1 {\n padding-bottom: 0.25rem !important;\n }\n .pl-md-1,\n .px-md-1 {\n padding-left: 0.25rem !important;\n }\n .p-md-2 {\n padding: 0.5rem !important;\n }\n .pt-md-2,\n .py-md-2 {\n padding-top: 0.5rem !important;\n }\n .pr-md-2,\n .px-md-2 {\n padding-right: 0.5rem !important;\n }\n .pb-md-2,\n .py-md-2 {\n padding-bottom: 0.5rem !important;\n }\n .pl-md-2,\n .px-md-2 {\n padding-left: 0.5rem !important;\n }\n .p-md-3 {\n padding: 1rem !important;\n }\n .pt-md-3,\n .py-md-3 {\n padding-top: 1rem !important;\n }\n .pr-md-3,\n .px-md-3 {\n padding-right: 1rem !important;\n }\n .pb-md-3,\n .py-md-3 {\n padding-bottom: 1rem !important;\n }\n .pl-md-3,\n .px-md-3 {\n padding-left: 1rem !important;\n }\n .p-md-4 {\n padding: 1.5rem !important;\n }\n .pt-md-4,\n .py-md-4 {\n padding-top: 1.5rem !important;\n }\n .pr-md-4,\n .px-md-4 {\n padding-right: 1.5rem !important;\n }\n .pb-md-4,\n .py-md-4 {\n padding-bottom: 1.5rem !important;\n }\n .pl-md-4,\n .px-md-4 {\n padding-left: 1.5rem !important;\n }\n .p-md-5 {\n padding: 3rem !important;\n }\n .pt-md-5,\n .py-md-5 {\n padding-top: 3rem !important;\n }\n .pr-md-5,\n .px-md-5 {\n padding-right: 3rem !important;\n }\n .pb-md-5,\n .py-md-5 {\n padding-bottom: 3rem !important;\n }\n .pl-md-5,\n .px-md-5 {\n padding-left: 3rem !important;\n }\n .m-md-n1 {\n margin: -0.25rem !important;\n }\n .mt-md-n1,\n .my-md-n1 {\n margin-top: -0.25rem !important;\n }\n .mr-md-n1,\n .mx-md-n1 {\n margin-right: -0.25rem !important;\n }\n .mb-md-n1,\n .my-md-n1 {\n margin-bottom: -0.25rem !important;\n }\n .ml-md-n1,\n .mx-md-n1 {\n margin-left: -0.25rem !important;\n }\n .m-md-n2 {\n margin: -0.5rem !important;\n }\n .mt-md-n2,\n .my-md-n2 {\n margin-top: -0.5rem !important;\n }\n .mr-md-n2,\n .mx-md-n2 {\n margin-right: -0.5rem !important;\n }\n .mb-md-n2,\n .my-md-n2 {\n margin-bottom: -0.5rem !important;\n }\n .ml-md-n2,\n .mx-md-n2 {\n margin-left: -0.5rem !important;\n }\n .m-md-n3 {\n margin: -1rem !important;\n }\n .mt-md-n3,\n .my-md-n3 {\n margin-top: -1rem !important;\n }\n .mr-md-n3,\n .mx-md-n3 {\n margin-right: -1rem !important;\n }\n .mb-md-n3,\n .my-md-n3 {\n margin-bottom: -1rem !important;\n }\n .ml-md-n3,\n .mx-md-n3 {\n margin-left: -1rem !important;\n }\n .m-md-n4 {\n margin: -1.5rem !important;\n }\n .mt-md-n4,\n .my-md-n4 {\n margin-top: -1.5rem !important;\n }\n .mr-md-n4,\n .mx-md-n4 {\n margin-right: -1.5rem !important;\n }\n .mb-md-n4,\n .my-md-n4 {\n margin-bottom: -1.5rem !important;\n }\n .ml-md-n4,\n .mx-md-n4 {\n margin-left: -1.5rem !important;\n }\n .m-md-n5 {\n margin: -3rem !important;\n }\n .mt-md-n5,\n .my-md-n5 {\n margin-top: -3rem !important;\n }\n .mr-md-n5,\n .mx-md-n5 {\n margin-right: -3rem !important;\n }\n .mb-md-n5,\n .my-md-n5 {\n margin-bottom: -3rem !important;\n }\n .ml-md-n5,\n .mx-md-n5 {\n margin-left: -3rem !important;\n }\n .m-md-auto {\n margin: auto !important;\n }\n .mt-md-auto,\n .my-md-auto {\n margin-top: auto !important;\n }\n .mr-md-auto,\n .mx-md-auto {\n margin-right: auto !important;\n }\n .mb-md-auto,\n .my-md-auto {\n margin-bottom: auto !important;\n }\n .ml-md-auto,\n .mx-md-auto {\n margin-left: auto !important;\n }\n}\n\n@media (min-width: 992px) {\n .m-lg-0 {\n margin: 0 !important;\n }\n .mt-lg-0,\n .my-lg-0 {\n margin-top: 0 !important;\n }\n .mr-lg-0,\n .mx-lg-0 {\n margin-right: 0 !important;\n }\n .mb-lg-0,\n .my-lg-0 {\n margin-bottom: 0 !important;\n }\n .ml-lg-0,\n .mx-lg-0 {\n margin-left: 0 !important;\n }\n .m-lg-1 {\n margin: 0.25rem !important;\n }\n .mt-lg-1,\n .my-lg-1 {\n margin-top: 0.25rem !important;\n }\n .mr-lg-1,\n .mx-lg-1 {\n margin-right: 0.25rem !important;\n }\n .mb-lg-1,\n .my-lg-1 {\n margin-bottom: 0.25rem !important;\n }\n .ml-lg-1,\n .mx-lg-1 {\n margin-left: 0.25rem !important;\n }\n .m-lg-2 {\n margin: 0.5rem !important;\n }\n .mt-lg-2,\n .my-lg-2 {\n margin-top: 0.5rem !important;\n }\n .mr-lg-2,\n .mx-lg-2 {\n margin-right: 0.5rem !important;\n }\n .mb-lg-2,\n .my-lg-2 {\n margin-bottom: 0.5rem !important;\n }\n .ml-lg-2,\n .mx-lg-2 {\n margin-left: 0.5rem !important;\n }\n .m-lg-3 {\n margin: 1rem !important;\n }\n .mt-lg-3,\n .my-lg-3 {\n margin-top: 1rem !important;\n }\n .mr-lg-3,\n .mx-lg-3 {\n margin-right: 1rem !important;\n }\n .mb-lg-3,\n .my-lg-3 {\n margin-bottom: 1rem !important;\n }\n .ml-lg-3,\n .mx-lg-3 {\n margin-left: 1rem !important;\n }\n .m-lg-4 {\n margin: 1.5rem !important;\n }\n .mt-lg-4,\n .my-lg-4 {\n margin-top: 1.5rem !important;\n }\n .mr-lg-4,\n .mx-lg-4 {\n margin-right: 1.5rem !important;\n }\n .mb-lg-4,\n .my-lg-4 {\n margin-bottom: 1.5rem !important;\n }\n .ml-lg-4,\n .mx-lg-4 {\n margin-left: 1.5rem !important;\n }\n .m-lg-5 {\n margin: 3rem !important;\n }\n .mt-lg-5,\n .my-lg-5 {\n margin-top: 3rem !important;\n }\n .mr-lg-5,\n .mx-lg-5 {\n margin-right: 3rem !important;\n }\n .mb-lg-5,\n .my-lg-5 {\n margin-bottom: 3rem !important;\n }\n .ml-lg-5,\n .mx-lg-5 {\n margin-left: 3rem !important;\n }\n .p-lg-0 {\n padding: 0 !important;\n }\n .pt-lg-0,\n .py-lg-0 {\n padding-top: 0 !important;\n }\n .pr-lg-0,\n .px-lg-0 {\n padding-right: 0 !important;\n }\n .pb-lg-0,\n .py-lg-0 {\n padding-bottom: 0 !important;\n }\n .pl-lg-0,\n .px-lg-0 {\n padding-left: 0 !important;\n }\n .p-lg-1 {\n padding: 0.25rem !important;\n }\n .pt-lg-1,\n .py-lg-1 {\n padding-top: 0.25rem !important;\n }\n .pr-lg-1,\n .px-lg-1 {\n padding-right: 0.25rem !important;\n }\n .pb-lg-1,\n .py-lg-1 {\n padding-bottom: 0.25rem !important;\n }\n .pl-lg-1,\n .px-lg-1 {\n padding-left: 0.25rem !important;\n }\n .p-lg-2 {\n padding: 0.5rem !important;\n }\n .pt-lg-2,\n .py-lg-2 {\n padding-top: 0.5rem !important;\n }\n .pr-lg-2,\n .px-lg-2 {\n padding-right: 0.5rem !important;\n }\n .pb-lg-2,\n .py-lg-2 {\n padding-bottom: 0.5rem !important;\n }\n .pl-lg-2,\n .px-lg-2 {\n padding-left: 0.5rem !important;\n }\n .p-lg-3 {\n padding: 1rem !important;\n }\n .pt-lg-3,\n .py-lg-3 {\n padding-top: 1rem !important;\n }\n .pr-lg-3,\n .px-lg-3 {\n padding-right: 1rem !important;\n }\n .pb-lg-3,\n .py-lg-3 {\n padding-bottom: 1rem !important;\n }\n .pl-lg-3,\n .px-lg-3 {\n padding-left: 1rem !important;\n }\n .p-lg-4 {\n padding: 1.5rem !important;\n }\n .pt-lg-4,\n .py-lg-4 {\n padding-top: 1.5rem !important;\n }\n .pr-lg-4,\n .px-lg-4 {\n padding-right: 1.5rem !important;\n }\n .pb-lg-4,\n .py-lg-4 {\n padding-bottom: 1.5rem !important;\n }\n .pl-lg-4,\n .px-lg-4 {\n padding-left: 1.5rem !important;\n }\n .p-lg-5 {\n padding: 3rem !important;\n }\n .pt-lg-5,\n .py-lg-5 {\n padding-top: 3rem !important;\n }\n .pr-lg-5,\n .px-lg-5 {\n padding-right: 3rem !important;\n }\n .pb-lg-5,\n .py-lg-5 {\n padding-bottom: 3rem !important;\n }\n .pl-lg-5,\n .px-lg-5 {\n padding-left: 3rem !important;\n }\n .m-lg-n1 {\n margin: -0.25rem !important;\n }\n .mt-lg-n1,\n .my-lg-n1 {\n margin-top: -0.25rem !important;\n }\n .mr-lg-n1,\n .mx-lg-n1 {\n margin-right: -0.25rem !important;\n }\n .mb-lg-n1,\n .my-lg-n1 {\n margin-bottom: -0.25rem !important;\n }\n .ml-lg-n1,\n .mx-lg-n1 {\n margin-left: -0.25rem !important;\n }\n .m-lg-n2 {\n margin: -0.5rem !important;\n }\n .mt-lg-n2,\n .my-lg-n2 {\n margin-top: -0.5rem !important;\n }\n .mr-lg-n2,\n .mx-lg-n2 {\n margin-right: -0.5rem !important;\n }\n .mb-lg-n2,\n .my-lg-n2 {\n margin-bottom: -0.5rem !important;\n }\n .ml-lg-n2,\n .mx-lg-n2 {\n margin-left: -0.5rem !important;\n }\n .m-lg-n3 {\n margin: -1rem !important;\n }\n .mt-lg-n3,\n .my-lg-n3 {\n margin-top: -1rem !important;\n }\n .mr-lg-n3,\n .mx-lg-n3 {\n margin-right: -1rem !important;\n }\n .mb-lg-n3,\n .my-lg-n3 {\n margin-bottom: -1rem !important;\n }\n .ml-lg-n3,\n .mx-lg-n3 {\n margin-left: -1rem !important;\n }\n .m-lg-n4 {\n margin: -1.5rem !important;\n }\n .mt-lg-n4,\n .my-lg-n4 {\n margin-top: -1.5rem !important;\n }\n .mr-lg-n4,\n .mx-lg-n4 {\n margin-right: -1.5rem !important;\n }\n .mb-lg-n4,\n .my-lg-n4 {\n margin-bottom: -1.5rem !important;\n }\n .ml-lg-n4,\n .mx-lg-n4 {\n margin-left: -1.5rem !important;\n }\n .m-lg-n5 {\n margin: -3rem !important;\n }\n .mt-lg-n5,\n .my-lg-n5 {\n margin-top: -3rem !important;\n }\n .mr-lg-n5,\n .mx-lg-n5 {\n margin-right: -3rem !important;\n }\n .mb-lg-n5,\n .my-lg-n5 {\n margin-bottom: -3rem !important;\n }\n .ml-lg-n5,\n .mx-lg-n5 {\n margin-left: -3rem !important;\n }\n .m-lg-auto {\n margin: auto !important;\n }\n .mt-lg-auto,\n .my-lg-auto {\n margin-top: auto !important;\n }\n .mr-lg-auto,\n .mx-lg-auto {\n margin-right: auto !important;\n }\n .mb-lg-auto,\n .my-lg-auto {\n margin-bottom: auto !important;\n }\n .ml-lg-auto,\n .mx-lg-auto {\n margin-left: auto !important;\n }\n}\n\n@media (min-width: 1200px) {\n .m-xl-0 {\n margin: 0 !important;\n }\n .mt-xl-0,\n .my-xl-0 {\n margin-top: 0 !important;\n }\n .mr-xl-0,\n .mx-xl-0 {\n margin-right: 0 !important;\n }\n .mb-xl-0,\n .my-xl-0 {\n margin-bottom: 0 !important;\n }\n .ml-xl-0,\n .mx-xl-0 {\n margin-left: 0 !important;\n }\n .m-xl-1 {\n margin: 0.25rem !important;\n }\n .mt-xl-1,\n .my-xl-1 {\n margin-top: 0.25rem !important;\n }\n .mr-xl-1,\n .mx-xl-1 {\n margin-right: 0.25rem !important;\n }\n .mb-xl-1,\n .my-xl-1 {\n margin-bottom: 0.25rem !important;\n }\n .ml-xl-1,\n .mx-xl-1 {\n margin-left: 0.25rem !important;\n }\n .m-xl-2 {\n margin: 0.5rem !important;\n }\n .mt-xl-2,\n .my-xl-2 {\n margin-top: 0.5rem !important;\n }\n .mr-xl-2,\n .mx-xl-2 {\n margin-right: 0.5rem !important;\n }\n .mb-xl-2,\n .my-xl-2 {\n margin-bottom: 0.5rem !important;\n }\n .ml-xl-2,\n .mx-xl-2 {\n margin-left: 0.5rem !important;\n }\n .m-xl-3 {\n margin: 1rem !important;\n }\n .mt-xl-3,\n .my-xl-3 {\n margin-top: 1rem !important;\n }\n .mr-xl-3,\n .mx-xl-3 {\n margin-right: 1rem !important;\n }\n .mb-xl-3,\n .my-xl-3 {\n margin-bottom: 1rem !important;\n }\n .ml-xl-3,\n .mx-xl-3 {\n margin-left: 1rem !important;\n }\n .m-xl-4 {\n margin: 1.5rem !important;\n }\n .mt-xl-4,\n .my-xl-4 {\n margin-top: 1.5rem !important;\n }\n .mr-xl-4,\n .mx-xl-4 {\n margin-right: 1.5rem !important;\n }\n .mb-xl-4,\n .my-xl-4 {\n margin-bottom: 1.5rem !important;\n }\n .ml-xl-4,\n .mx-xl-4 {\n margin-left: 1.5rem !important;\n }\n .m-xl-5 {\n margin: 3rem !important;\n }\n .mt-xl-5,\n .my-xl-5 {\n margin-top: 3rem !important;\n }\n .mr-xl-5,\n .mx-xl-5 {\n margin-right: 3rem !important;\n }\n .mb-xl-5,\n .my-xl-5 {\n margin-bottom: 3rem !important;\n }\n .ml-xl-5,\n .mx-xl-5 {\n margin-left: 3rem !important;\n }\n .p-xl-0 {\n padding: 0 !important;\n }\n .pt-xl-0,\n .py-xl-0 {\n padding-top: 0 !important;\n }\n .pr-xl-0,\n .px-xl-0 {\n padding-right: 0 !important;\n }\n .pb-xl-0,\n .py-xl-0 {\n padding-bottom: 0 !important;\n }\n .pl-xl-0,\n .px-xl-0 {\n padding-left: 0 !important;\n }\n .p-xl-1 {\n padding: 0.25rem !important;\n }\n .pt-xl-1,\n .py-xl-1 {\n padding-top: 0.25rem !important;\n }\n .pr-xl-1,\n .px-xl-1 {\n padding-right: 0.25rem !important;\n }\n .pb-xl-1,\n .py-xl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pl-xl-1,\n .px-xl-1 {\n padding-left: 0.25rem !important;\n }\n .p-xl-2 {\n padding: 0.5rem !important;\n }\n .pt-xl-2,\n .py-xl-2 {\n padding-top: 0.5rem !important;\n }\n .pr-xl-2,\n .px-xl-2 {\n padding-right: 0.5rem !important;\n }\n .pb-xl-2,\n .py-xl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pl-xl-2,\n .px-xl-2 {\n padding-left: 0.5rem !important;\n }\n .p-xl-3 {\n padding: 1rem !important;\n }\n .pt-xl-3,\n .py-xl-3 {\n padding-top: 1rem !important;\n }\n .pr-xl-3,\n .px-xl-3 {\n padding-right: 1rem !important;\n }\n .pb-xl-3,\n .py-xl-3 {\n padding-bottom: 1rem !important;\n }\n .pl-xl-3,\n .px-xl-3 {\n padding-left: 1rem !important;\n }\n .p-xl-4 {\n padding: 1.5rem !important;\n }\n .pt-xl-4,\n .py-xl-4 {\n padding-top: 1.5rem !important;\n }\n .pr-xl-4,\n .px-xl-4 {\n padding-right: 1.5rem !important;\n }\n .pb-xl-4,\n .py-xl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pl-xl-4,\n .px-xl-4 {\n padding-left: 1.5rem !important;\n }\n .p-xl-5 {\n padding: 3rem !important;\n }\n .pt-xl-5,\n .py-xl-5 {\n padding-top: 3rem !important;\n }\n .pr-xl-5,\n .px-xl-5 {\n padding-right: 3rem !important;\n }\n .pb-xl-5,\n .py-xl-5 {\n padding-bottom: 3rem !important;\n }\n .pl-xl-5,\n .px-xl-5 {\n padding-left: 3rem !important;\n }\n .m-xl-n1 {\n margin: -0.25rem !important;\n }\n .mt-xl-n1,\n .my-xl-n1 {\n margin-top: -0.25rem !important;\n }\n .mr-xl-n1,\n .mx-xl-n1 {\n margin-right: -0.25rem !important;\n }\n .mb-xl-n1,\n .my-xl-n1 {\n margin-bottom: -0.25rem !important;\n }\n .ml-xl-n1,\n .mx-xl-n1 {\n margin-left: -0.25rem !important;\n }\n .m-xl-n2 {\n margin: -0.5rem !important;\n }\n .mt-xl-n2,\n .my-xl-n2 {\n margin-top: -0.5rem !important;\n }\n .mr-xl-n2,\n .mx-xl-n2 {\n margin-right: -0.5rem !important;\n }\n .mb-xl-n2,\n .my-xl-n2 {\n margin-bottom: -0.5rem !important;\n }\n .ml-xl-n2,\n .mx-xl-n2 {\n margin-left: -0.5rem !important;\n }\n .m-xl-n3 {\n margin: -1rem !important;\n }\n .mt-xl-n3,\n .my-xl-n3 {\n margin-top: -1rem !important;\n }\n .mr-xl-n3,\n .mx-xl-n3 {\n margin-right: -1rem !important;\n }\n .mb-xl-n3,\n .my-xl-n3 {\n margin-bottom: -1rem !important;\n }\n .ml-xl-n3,\n .mx-xl-n3 {\n margin-left: -1rem !important;\n }\n .m-xl-n4 {\n margin: -1.5rem !important;\n }\n .mt-xl-n4,\n .my-xl-n4 {\n margin-top: -1.5rem !important;\n }\n .mr-xl-n4,\n .mx-xl-n4 {\n margin-right: -1.5rem !important;\n }\n .mb-xl-n4,\n .my-xl-n4 {\n margin-bottom: -1.5rem !important;\n }\n .ml-xl-n4,\n .mx-xl-n4 {\n margin-left: -1.5rem !important;\n }\n .m-xl-n5 {\n margin: -3rem !important;\n }\n .mt-xl-n5,\n .my-xl-n5 {\n margin-top: -3rem !important;\n }\n .mr-xl-n5,\n .mx-xl-n5 {\n margin-right: -3rem !important;\n }\n .mb-xl-n5,\n .my-xl-n5 {\n margin-bottom: -3rem !important;\n }\n .ml-xl-n5,\n .mx-xl-n5 {\n margin-left: -3rem !important;\n }\n .m-xl-auto {\n margin: auto !important;\n }\n .mt-xl-auto,\n .my-xl-auto {\n margin-top: auto !important;\n }\n .mr-xl-auto,\n .mx-xl-auto {\n margin-right: auto !important;\n }\n .mb-xl-auto,\n .my-xl-auto {\n margin-bottom: auto !important;\n }\n .ml-xl-auto,\n .mx-xl-auto {\n margin-left: auto !important;\n }\n}\n\n.text-monospace {\n font-family: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace !important;\n}\n\n.text-justify {\n text-align: justify !important;\n}\n\n.text-wrap {\n white-space: normal !important;\n}\n\n.text-nowrap {\n white-space: nowrap !important;\n}\n\n.text-truncate {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.text-left {\n text-align: left !important;\n}\n\n.text-right {\n text-align: right !important;\n}\n\n.text-center {\n text-align: center !important;\n}\n\n@media (min-width: 576px) {\n .text-sm-left {\n text-align: left !important;\n }\n .text-sm-right {\n text-align: right !important;\n }\n .text-sm-center {\n text-align: center !important;\n }\n}\n\n@media (min-width: 768px) {\n .text-md-left {\n text-align: left !important;\n }\n .text-md-right {\n text-align: right !important;\n }\n .text-md-center {\n text-align: center !important;\n }\n}\n\n@media (min-width: 992px) {\n .text-lg-left {\n text-align: left !important;\n }\n .text-lg-right {\n text-align: right !important;\n }\n .text-lg-center {\n text-align: center !important;\n }\n}\n\n@media (min-width: 1200px) {\n .text-xl-left {\n text-align: left !important;\n }\n .text-xl-right {\n text-align: right !important;\n }\n .text-xl-center {\n text-align: center !important;\n }\n}\n\n.text-lowercase {\n text-transform: lowercase !important;\n}\n\n.text-uppercase {\n text-transform: uppercase !important;\n}\n\n.text-capitalize {\n text-transform: capitalize !important;\n}\n\n.font-weight-light {\n font-weight: 300 !important;\n}\n\n.font-weight-lighter {\n font-weight: lighter !important;\n}\n\n.font-weight-normal {\n font-weight: 400 !important;\n}\n\n.font-weight-bold {\n font-weight: 700 !important;\n}\n\n.font-weight-bolder {\n font-weight: bolder !important;\n}\n\n.font-italic {\n font-style: italic !important;\n}\n\n.text-white {\n color: #fff !important;\n}\n\n.text-primary {\n color: #007bff !important;\n}\n\na.text-primary:hover, a.text-primary:focus {\n color: #0056b3 !important;\n}\n\n.text-secondary {\n color: #6c757d !important;\n}\n\na.text-secondary:hover, a.text-secondary:focus {\n color: #494f54 !important;\n}\n\n.text-success {\n color: #28a745 !important;\n}\n\na.text-success:hover, a.text-success:focus {\n color: #19692c !important;\n}\n\n.text-info {\n color: #17a2b8 !important;\n}\n\na.text-info:hover, a.text-info:focus {\n color: #0f6674 !important;\n}\n\n.text-warning {\n color: #ffc107 !important;\n}\n\na.text-warning:hover, a.text-warning:focus {\n color: #ba8b00 !important;\n}\n\n.text-danger {\n color: #dc3545 !important;\n}\n\na.text-danger:hover, a.text-danger:focus {\n color: #a71d2a !important;\n}\n\n.text-light {\n color: #f8f9fa !important;\n}\n\na.text-light:hover, a.text-light:focus {\n color: #cbd3da !important;\n}\n\n.text-dark {\n color: #343a40 !important;\n}\n\na.text-dark:hover, a.text-dark:focus {\n color: #121416 !important;\n}\n\n.text-body {\n color: #212529 !important;\n}\n\n.text-muted {\n color: #6c757d !important;\n}\n\n.text-black-50 {\n color: rgba(0, 0, 0, 0.5) !important;\n}\n\n.text-white-50 {\n color: rgba(255, 255, 255, 0.5) !important;\n}\n\n.text-hide {\n font: 0/0 a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n\n.text-decoration-none {\n text-decoration: none !important;\n}\n\n.text-break {\n word-break: break-word !important;\n overflow-wrap: break-word !important;\n}\n\n.text-reset {\n color: inherit !important;\n}\n\n.visible {\n visibility: visible !important;\n}\n\n.invisible {\n visibility: hidden !important;\n}\n\n@media print {\n *,\n *::before,\n *::after {\n text-shadow: none !important;\n box-shadow: none !important;\n }\n a:not(.btn) {\n text-decoration: underline;\n }\n abbr[title]::after {\n content: \" (\" attr(title) \")\";\n }\n pre {\n white-space: pre-wrap !important;\n }\n pre,\n blockquote {\n border: 1px solid #adb5bd;\n page-break-inside: avoid;\n }\n thead {\n display: table-header-group;\n }\n tr,\n img {\n page-break-inside: avoid;\n }\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n h2,\n h3 {\n page-break-after: avoid;\n }\n @page {\n size: a3;\n }\n body {\n min-width: 992px !important;\n }\n .container {\n min-width: 992px !important;\n }\n .navbar {\n display: none;\n }\n .badge {\n border: 1px solid #000;\n }\n .table {\n border-collapse: collapse !important;\n }\n .table td,\n .table th {\n background-color: #fff !important;\n }\n .table-bordered th,\n .table-bordered td {\n border: 1px solid #dee2e6 !important;\n }\n .table-dark {\n color: inherit;\n }\n .table-dark th,\n .table-dark td,\n .table-dark thead th,\n .table-dark tbody + tbody {\n border-color: #dee2e6;\n }\n .table .thead-dark th {\n color: inherit;\n border-color: #dee2e6;\n }\n}\n\n/*# sourceMappingURL=bootstrap.css.map */","// Hover mixin and `$enable-hover-media-query` are deprecated.\n//\n// Originally added during our alphas and maintained during betas, this mixin was\n// designed to prevent `:hover` stickiness on iOS-an issue where hover styles\n// would persist after initial touch.\n//\n// For backward compatibility, we've kept these mixins and updated them to\n// always return their regular pseudo-classes instead of a shimmed media query.\n//\n// Issue: https://github.com/twbs/bootstrap/issues/25195\n\n@mixin hover {\n &:hover { @content; }\n}\n\n@mixin hover-focus {\n &:hover,\n &:focus {\n @content;\n }\n}\n\n@mixin plain-hover-focus {\n &,\n &:hover,\n &:focus {\n @content;\n }\n}\n\n@mixin hover-focus-active {\n &:hover,\n &:focus,\n &:active {\n @content;\n }\n}\n","// stylelint-disable declaration-no-important, selector-list-comma-newline-after\n\n//\n// Headings\n//\n\nh1, h2, h3, h4, h5, h6,\n.h1, .h2, .h3, .h4, .h5, .h6 {\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: $headings-color;\n}\n\nh1, .h1 { @include font-size($h1-font-size); }\nh2, .h2 { @include font-size($h2-font-size); }\nh3, .h3 { @include font-size($h3-font-size); }\nh4, .h4 { @include font-size($h4-font-size); }\nh5, .h5 { @include font-size($h5-font-size); }\nh6, .h6 { @include font-size($h6-font-size); }\n\n.lead {\n @include font-size($lead-font-size);\n font-weight: $lead-font-weight;\n}\n\n// Type display classes\n.display-1 {\n @include font-size($display1-size);\n font-weight: $display1-weight;\n line-height: $display-line-height;\n}\n.display-2 {\n @include font-size($display2-size);\n font-weight: $display2-weight;\n line-height: $display-line-height;\n}\n.display-3 {\n @include font-size($display3-size);\n font-weight: $display3-weight;\n line-height: $display-line-height;\n}\n.display-4 {\n @include font-size($display4-size);\n font-weight: $display4-weight;\n line-height: $display-line-height;\n}\n\n\n//\n// Horizontal rules\n//\n\nhr {\n margin-top: $hr-margin-y;\n margin-bottom: $hr-margin-y;\n border: 0;\n border-top: $hr-border-width solid $hr-border-color;\n}\n\n\n//\n// Emphasis\n//\n\nsmall,\n.small {\n @include font-size($small-font-size);\n font-weight: $font-weight-normal;\n}\n\nmark,\n.mark {\n padding: $mark-padding;\n background-color: $mark-bg;\n}\n\n\n//\n// Lists\n//\n\n.list-unstyled {\n @include list-unstyled;\n}\n\n// Inline turns list items into inline-block\n.list-inline {\n @include list-unstyled;\n}\n.list-inline-item {\n display: inline-block;\n\n &:not(:last-child) {\n margin-right: $list-inline-padding;\n }\n}\n\n\n//\n// Misc\n//\n\n// Builds on `abbr`\n.initialism {\n @include font-size(90%);\n text-transform: uppercase;\n}\n\n// Blockquotes\n.blockquote {\n margin-bottom: $spacer;\n @include font-size($blockquote-font-size);\n}\n\n.blockquote-footer {\n display: block;\n @include font-size($blockquote-small-font-size);\n color: $blockquote-small-color;\n\n &::before {\n content: \"\\2014\\00A0\"; // em dash, nbsp\n }\n}\n","// Lists\n\n// Unstyled keeps list items block level, just removes default browser padding and list-style\n@mixin list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n","// Responsive images (ensure images don't scale beyond their parents)\n//\n// This is purposefully opt-in via an explicit class rather than being the default for all ``s.\n// We previously tried the \"images are responsive by default\" approach in Bootstrap v2,\n// and abandoned it in Bootstrap v3 because it breaks lots of third-party widgets (including Google Maps)\n// which weren't expecting the images within themselves to be involuntarily resized.\n// See also https://github.com/twbs/bootstrap/issues/18178\n.img-fluid {\n @include img-fluid;\n}\n\n\n// Image thumbnails\n.img-thumbnail {\n padding: $thumbnail-padding;\n background-color: $thumbnail-bg;\n border: $thumbnail-border-width solid $thumbnail-border-color;\n @include border-radius($thumbnail-border-radius);\n @include box-shadow($thumbnail-box-shadow);\n\n // Keep them at most 100% wide\n @include img-fluid;\n}\n\n//\n// Figures\n//\n\n.figure {\n // Ensures the caption's text aligns with the image.\n display: inline-block;\n}\n\n.figure-img {\n margin-bottom: $spacer / 2;\n line-height: 1;\n}\n\n.figure-caption {\n @include font-size($figure-caption-font-size);\n color: $figure-caption-color;\n}\n","// Image Mixins\n// - Responsive image\n// - Retina image\n\n\n// Responsive image\n//\n// Keep images from scaling beyond the width of their parents.\n\n@mixin img-fluid {\n // Part 1: Set a maximum relative to the parent\n max-width: 100%;\n // Part 2: Override the height to auto, otherwise images will be stretched\n // when setting a width and height attribute on the img element.\n height: auto;\n}\n\n\n// Retina image\n//\n// Short retina mixin for setting background-image and -size.\n\n@mixin img-retina($file-1x, $file-2x, $width-1x, $height-1x) {\n background-image: url($file-1x);\n\n // Autoprefixer takes care of adding -webkit-min-device-pixel-ratio and -o-min-device-pixel-ratio,\n // but doesn't convert dppx=>dpi.\n // There's no such thing as unprefixed min-device-pixel-ratio since it's nonstandard.\n // Compatibility info: https://caniuse.com/#feat=css-media-resolution\n @media only screen and (min-resolution: 192dpi), // IE9-11 don't support dppx\n only screen and (min-resolution: 2dppx) { // Standardized\n background-image: url($file-2x);\n background-size: $width-1x $height-1x;\n }\n @include deprecate(\"`img-retina()`\", \"v4.3.0\", \"v5\");\n}\n","// stylelint-disable property-blacklist\n// Single side border-radius\n\n@mixin border-radius($radius: $border-radius, $fallback-border-radius: false) {\n @if $enable-rounded {\n border-radius: $radius;\n }\n @else if $fallback-border-radius != false {\n border-radius: $fallback-border-radius;\n }\n}\n\n@mixin border-top-radius($radius) {\n @if $enable-rounded {\n border-top-left-radius: $radius;\n border-top-right-radius: $radius;\n }\n}\n\n@mixin border-right-radius($radius) {\n @if $enable-rounded {\n border-top-right-radius: $radius;\n border-bottom-right-radius: $radius;\n }\n}\n\n@mixin border-bottom-radius($radius) {\n @if $enable-rounded {\n border-bottom-right-radius: $radius;\n border-bottom-left-radius: $radius;\n }\n}\n\n@mixin border-left-radius($radius) {\n @if $enable-rounded {\n border-top-left-radius: $radius;\n border-bottom-left-radius: $radius;\n }\n}\n\n@mixin border-top-left-radius($radius) {\n @if $enable-rounded {\n border-top-left-radius: $radius;\n }\n}\n\n@mixin border-top-right-radius($radius) {\n @if $enable-rounded {\n border-top-right-radius: $radius;\n }\n}\n\n@mixin border-bottom-right-radius($radius) {\n @if $enable-rounded {\n border-bottom-right-radius: $radius;\n }\n}\n\n@mixin border-bottom-left-radius($radius) {\n @if $enable-rounded {\n border-bottom-left-radius: $radius;\n }\n}\n","// Inline code\ncode {\n @include font-size($code-font-size);\n color: $code-color;\n word-break: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\n// User input typically entered via keyboard\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n @include box-shadow($kbd-box-shadow);\n\n kbd {\n padding: 0;\n @include font-size(100%);\n font-weight: $nested-kbd-font-weight;\n @include box-shadow(none);\n }\n}\n\n// Blocks of code\npre {\n display: block;\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\n// Enable scrollable blocks of code\n.pre-scrollable {\n max-height: $pre-scrollable-max-height;\n overflow-y: scroll;\n}\n","// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n@if $enable-grid-classes {\n .container {\n @include make-container();\n @include make-container-max-widths();\n }\n}\n\n// Fluid container\n//\n// Utilizes the mixin meant for fixed width containers, but with 100% width for\n// fluid, full width layouts.\n\n@if $enable-grid-classes {\n .container-fluid {\n @include make-container();\n }\n}\n\n// Row\n//\n// Rows contain and clear the floats of your columns.\n\n@if $enable-grid-classes {\n .row {\n @include make-row();\n }\n\n // Remove the negative margin from default .row, then the horizontal padding\n // from all immediate children columns (to prevent runaway style inheritance).\n .no-gutters {\n margin-right: 0;\n margin-left: 0;\n\n > .col,\n > [class*=\"col-\"] {\n padding-right: 0;\n padding-left: 0;\n }\n }\n}\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n@if $enable-grid-classes {\n @include make-grid-columns();\n}\n","/// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n@mixin make-container($gutter: $grid-gutter-width) {\n width: 100%;\n padding-right: $gutter / 2;\n padding-left: $gutter / 2;\n margin-right: auto;\n margin-left: auto;\n}\n\n\n// For each breakpoint, define the maximum width of the container in a media query\n@mixin make-container-max-widths($max-widths: $container-max-widths, $breakpoints: $grid-breakpoints) {\n @each $breakpoint, $container-max-width in $max-widths {\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n max-width: $container-max-width;\n }\n }\n}\n\n@mixin make-row($gutter: $grid-gutter-width) {\n display: flex;\n flex-wrap: wrap;\n margin-right: -$gutter / 2;\n margin-left: -$gutter / 2;\n}\n\n@mixin make-col-ready($gutter: $grid-gutter-width) {\n position: relative;\n // Prevent columns from becoming too narrow when at smaller grid tiers by\n // always setting `width: 100%;`. This works because we use `flex` values\n // later on to override this initial width.\n width: 100%;\n padding-right: $gutter / 2;\n padding-left: $gutter / 2;\n}\n\n@mixin make-col($size, $columns: $grid-columns) {\n flex: 0 0 percentage($size / $columns);\n // Add a `max-width` to ensure content within each column does not blow out\n // the width of the column. Applies to IE10+ and Firefox. Chrome and Safari\n // do not appear to require this.\n max-width: percentage($size / $columns);\n}\n\n@mixin make-col-offset($size, $columns: $grid-columns) {\n $num: $size / $columns;\n margin-left: if($num == 0, 0, percentage($num));\n}\n","// Breakpoint viewport sizes and media queries.\n//\n// Breakpoints are defined as a map of (name: minimum width), order from small to large:\n//\n// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)\n//\n// The map defined in the `$grid-breakpoints` global variable is used as the `$breakpoints` argument by default.\n\n// Name of the next breakpoint, or null for the last breakpoint.\n//\n// >> breakpoint-next(sm)\n// md\n// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// md\n// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl))\n// md\n@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {\n $n: index($breakpoint-names, $name);\n @return if($n != null and $n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);\n}\n\n// Minimum breakpoint width. Null for the smallest (first) breakpoint.\n//\n// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// 576px\n@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {\n $min: map-get($breakpoints, $name);\n @return if($min != 0, $min, null);\n}\n\n// Maximum breakpoint width. Null for the largest (last) breakpoint.\n// The maximum value is calculated as the minimum of the next one less 0.02px\n// to work around the limitations of `min-` and `max-` prefixes and viewports with fractional widths.\n// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max\n// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.\n// See https://bugs.webkit.org/show_bug.cgi?id=178261\n//\n// >> breakpoint-max(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// 767.98px\n@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {\n $next: breakpoint-next($name, $breakpoints);\n @return if($next, breakpoint-min($next, $breakpoints) - .02, null);\n}\n\n// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front.\n// Useful for making responsive utilities.\n//\n// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// \"\" (Returns a blank string)\n// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// \"-sm\"\n@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {\n @return if(breakpoint-min($name, $breakpoints) == null, \"\", \"-#{$name}\");\n}\n\n// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.\n// Makes the @content apply to the given breakpoint and wider.\n@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n @if $min {\n @media (min-width: $min) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media of at most the maximum breakpoint width. No query for the largest breakpoint.\n// Makes the @content apply to the given breakpoint and narrower.\n@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {\n $max: breakpoint-max($name, $breakpoints);\n @if $max {\n @media (max-width: $max) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media that spans multiple breakpoint widths.\n// Makes the @content apply between the min and max breakpoints\n@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($lower, $breakpoints);\n $max: breakpoint-max($upper, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($lower, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($upper, $breakpoints) {\n @content;\n }\n }\n}\n\n// Media between the breakpoint's minimum and maximum widths.\n// No minimum for the smallest breakpoint, and no maximum for the largest one.\n// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.\n@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n $max: breakpoint-max($name, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($name, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($name, $breakpoints) {\n @content;\n }\n }\n}\n","// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `$grid-columns`.\n\n@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) {\n // Common properties for all breakpoints\n %grid-column {\n position: relative;\n width: 100%;\n padding-right: $gutter / 2;\n padding-left: $gutter / 2;\n }\n\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n // Allow columns to stretch full width below their breakpoints\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @extend %grid-column;\n }\n }\n .col#{$infix},\n .col#{$infix}-auto {\n @extend %grid-column;\n }\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n // Provide basic `.col-{bp}` classes for equal-width flexbox columns\n .col#{$infix} {\n flex-basis: 0;\n flex-grow: 1;\n max-width: 100%;\n }\n .col#{$infix}-auto {\n flex: 0 0 auto;\n width: auto;\n max-width: 100%; // Reset earlier grid tiers\n }\n\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @include make-col($i, $columns);\n }\n }\n\n .order#{$infix}-first { order: -1; }\n\n .order#{$infix}-last { order: $columns + 1; }\n\n @for $i from 0 through $columns {\n .order#{$infix}-#{$i} { order: $i; }\n }\n\n // `$columns - 1` because offsetting by the width of an entire row isn't possible\n @for $i from 0 through ($columns - 1) {\n @if not ($infix == \"\" and $i == 0) { // Avoid emitting useless .offset-0\n .offset#{$infix}-#{$i} {\n @include make-col-offset($i, $columns);\n }\n }\n }\n }\n }\n}\n","//\n// Basic Bootstrap table\n//\n\n.table {\n width: 100%;\n margin-bottom: $spacer;\n color: $table-color;\n background-color: $table-bg; // Reset for nesting within parents with `background-color`.\n\n th,\n td {\n padding: $table-cell-padding;\n vertical-align: top;\n border-top: $table-border-width solid $table-border-color;\n }\n\n thead th {\n vertical-align: bottom;\n border-bottom: (2 * $table-border-width) solid $table-border-color;\n }\n\n tbody + tbody {\n border-top: (2 * $table-border-width) solid $table-border-color;\n }\n}\n\n\n//\n// Condensed table w/ half padding\n//\n\n.table-sm {\n th,\n td {\n padding: $table-cell-padding-sm;\n }\n}\n\n\n// Border versions\n//\n// Add or remove borders all around the table and between all the columns.\n\n.table-bordered {\n border: $table-border-width solid $table-border-color;\n\n th,\n td {\n border: $table-border-width solid $table-border-color;\n }\n\n thead {\n th,\n td {\n border-bottom-width: 2 * $table-border-width;\n }\n }\n}\n\n.table-borderless {\n th,\n td,\n thead th,\n tbody + tbody {\n border: 0;\n }\n}\n\n// Zebra-striping\n//\n// Default zebra-stripe styles (alternating gray and transparent backgrounds)\n\n.table-striped {\n tbody tr:nth-of-type(#{$table-striped-order}) {\n background-color: $table-accent-bg;\n }\n}\n\n\n// Hover effect\n//\n// Placed here since it has to come after the potential zebra striping\n\n.table-hover {\n tbody tr {\n @include hover {\n color: $table-hover-color;\n background-color: $table-hover-bg;\n }\n }\n}\n\n\n// Table backgrounds\n//\n// Exact selectors below required to override `.table-striped` and prevent\n// inheritance to nested tables.\n\n@each $color, $value in $theme-colors {\n @include table-row-variant($color, theme-color-level($color, $table-bg-level), theme-color-level($color, $table-border-level));\n}\n\n@include table-row-variant(active, $table-active-bg);\n\n\n// Dark styles\n//\n// Same table markup, but inverted color scheme: dark background and light text.\n\n// stylelint-disable-next-line no-duplicate-selectors\n.table {\n .thead-dark {\n th {\n color: $table-dark-color;\n background-color: $table-dark-bg;\n border-color: $table-dark-border-color;\n }\n }\n\n .thead-light {\n th {\n color: $table-head-color;\n background-color: $table-head-bg;\n border-color: $table-border-color;\n }\n }\n}\n\n.table-dark {\n color: $table-dark-color;\n background-color: $table-dark-bg;\n\n th,\n td,\n thead th {\n border-color: $table-dark-border-color;\n }\n\n &.table-bordered {\n border: 0;\n }\n\n &.table-striped {\n tbody tr:nth-of-type(odd) {\n background-color: $table-dark-accent-bg;\n }\n }\n\n &.table-hover {\n tbody tr {\n @include hover {\n color: $table-dark-hover-color;\n background-color: $table-dark-hover-bg;\n }\n }\n }\n}\n\n\n// Responsive tables\n//\n// Generate series of `.table-responsive-*` classes for configuring the screen\n// size of where your table will overflow.\n\n.table-responsive {\n @each $breakpoint in map-keys($grid-breakpoints) {\n $next: breakpoint-next($breakpoint, $grid-breakpoints);\n $infix: breakpoint-infix($next, $grid-breakpoints);\n\n &#{$infix} {\n @include media-breakpoint-down($breakpoint) {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n\n // Prevent double border on horizontal scroll due to use of `display: block;`\n > .table-bordered {\n border: 0;\n }\n }\n }\n }\n}\n","// Tables\n\n@mixin table-row-variant($state, $background, $border: null) {\n // Exact selectors below required to override `.table-striped` and prevent\n // inheritance to nested tables.\n .table-#{$state} {\n &,\n > th,\n > td {\n background-color: $background;\n }\n\n @if $border != null {\n th,\n td,\n thead th,\n tbody + tbody {\n border-color: $border;\n }\n }\n }\n\n // Hover states for `.table-hover`\n // Note: this is not available for cells or rows within `thead` or `tfoot`.\n .table-hover {\n $hover-background: darken($background, 5%);\n\n .table-#{$state} {\n @include hover {\n background-color: $hover-background;\n\n > td,\n > th {\n background-color: $hover-background;\n }\n }\n }\n }\n}\n","// stylelint-disable selector-no-qualifying-type\n\n//\n// Textual form controls\n//\n\n.form-control {\n display: block;\n width: 100%;\n height: $input-height;\n padding: $input-padding-y $input-padding-x;\n font-family: $input-font-family;\n @include font-size($input-font-size);\n font-weight: $input-font-weight;\n line-height: $input-line-height;\n color: $input-color;\n background-color: $input-bg;\n background-clip: padding-box;\n border: $input-border-width solid $input-border-color;\n\n // Note: This has no effect on `s in CSS.\n @include border-radius($input-border-radius, 0);\n\n @include box-shadow($input-box-shadow);\n @include transition($input-transition);\n\n // Unstyle the caret on ` receives focus\n // in IE and (under certain conditions) Edge, as it looks bad and cannot be made to\n // match the appearance of the native widget.\n // See https://github.com/twbs/bootstrap/issues/19398.\n color: $input-color;\n background-color: $input-bg;\n }\n}\n\n// Make file inputs better match text inputs by forcing them to new lines.\n.form-control-file,\n.form-control-range {\n display: block;\n width: 100%;\n}\n\n\n//\n// Labels\n//\n\n// For use with horizontal and inline forms, when you need the label (or legend)\n// text to align with the form controls.\n.col-form-label {\n padding-top: calc(#{$input-padding-y} + #{$input-border-width});\n padding-bottom: calc(#{$input-padding-y} + #{$input-border-width});\n margin-bottom: 0; // Override the `