diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Configuration/MediaKindSettings.cs b/AdminUI/LearningHub.Nhs.AdminUI/Configuration/MediaKindSettings.cs new file mode 100644 index 000000000..82609fc07 --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Configuration/MediaKindSettings.cs @@ -0,0 +1,53 @@ +namespace LearningHub.Nhs.AdminUI.Configuration +{ + /// + /// Config AzureMediaSettings. + /// + public class MediaKindSettings + { + /// + /// Gets or sets subscription name. + /// + public string SubscriptionName { get; set; } + + /// + /// Gets or sets token. + /// + public string Token { get; set; } + + /// + /// Gets or sets storage media account name. + /// + public string StorageAccountName { get; set; } + + /// + /// Gets or sets media kind media service issuer. + /// + public string Issuer { get; set; } + + /// + /// Gets or sets media kind media service audience. + /// + public string Audience { get; set; } + + /// + /// Gets or sets the contentkey policyname. + /// + public string ContentKeyPolicyName { get; set; } + + /// + /// Gets or sets media kind media service jwt primary key secret. + /// + public string JWTPrimaryKeySecret { get; set; } + + /// + /// Gets or sets the media kind media kind MKPlayer licence key. + /// + public string MKPlayerLicence { get; set; } + + /// + /// Gets or sets the media kind blob connection string. + /// + public string MediaKindStorageConnectionString { get; set; } + } +} diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Configuration/WebSettings.cs b/AdminUI/LearningHub.Nhs.AdminUI/Configuration/WebSettings.cs index ace067941..f8e3c1565 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Configuration/WebSettings.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Configuration/WebSettings.cs @@ -77,6 +77,16 @@ public class WebSettings /// public string AzureFileStorageConnectionString { get; set; } + /// + /// Gets or sets the AzureSourceFileStorageConnectionString. + /// + public string AzureSourceArchiveStorageConnectionString { get; set; } + + /// + /// Gets or sets the AzurePurgedFileStorageConnectionString. + /// + public string AzureContentArchiveStorageConnectionString { get; set; } + /// /// Gets or sets the azure file storage resource share name. /// @@ -146,5 +156,10 @@ public class WebSettings /// Gets or sets the FileUploadSettings. /// public FileUploadSettingsModel FileUploadSettings { get; set; } = new FileUploadSettingsModel(); + + /// + /// Gets or sets the MediaKindSettings. + /// + public MediaKindSettings MediaKindSettings { get; set; } = new MediaKindSettings(); } } \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/ResourceController.cs b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/ResourceController.cs index 91c5052af..1a662d524 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/ResourceController.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/ResourceController.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using LearningHub.Nhs.AdminUI.Configuration; using LearningHub.Nhs.AdminUI.Extensions; + using LearningHub.Nhs.AdminUI.Helpers; using LearningHub.Nhs.AdminUI.Interfaces; using LearningHub.Nhs.AdminUI.Models; using LearningHub.Nhs.Models.Common; @@ -16,6 +17,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; + using Microsoft.FeatureManagement; /// /// Defines the . @@ -32,6 +34,11 @@ public class ResourceController : BaseController /// private readonly IOptions websettings; + /// + /// Defines the featureManager. + /// + private readonly IFeatureManager featureManager; + /// /// Defines the _logger. /// @@ -47,6 +54,11 @@ public class ResourceController : BaseController /// private IResourceService resourceService; + /// + /// Defines the _fileService. + /// + private IFileService fileService; + /// /// Initializes a new instance of the class. /// @@ -54,30 +66,40 @@ public class ResourceController : BaseController /// The config. /// The logger. /// The resourceService. + /// The fileService. /// /// The websettings. + /// The featureManager. public ResourceController( IWebHostEnvironment hostingEnvironment, IOptions config, ILogger logger, IResourceService resourceService, - IOptions websettings) + IFileService fileService, + IOptions websettings, + IFeatureManager featureManager) : base(hostingEnvironment) { this.logger = logger; this.websettings = websettings; this.config = config.Value; this.resourceService = resourceService; + this.fileService = fileService; + this.featureManager = featureManager; } /// /// The Details. /// /// The id. + /// The activeTab. + /// The status. /// The . [HttpGet] - public async Task Details(int id) + public async Task Details(int id, string activeTab = "details", string status = "") { var resource = await this.resourceService.GetResourceVersionExtendedViewModelAsync(id); + this.ViewBag.ActiveTab = activeTab; + this.ViewBag.Status = status; return this.View(resource); } @@ -120,6 +142,41 @@ public async Task GetValidationResults(int resourceVersionId) return this.PartialView("_ValidationResults", vm); } + /// + /// The GetDevIdDetails. + /// + /// The resourceVersionId. + /// The . + [HttpPost] + public async Task GetDevIdDetails(int resourceVersionId) + { + var vm = await this.resourceService.GetResourceVersionDevIdDetailsAsync(resourceVersionId); + + return this.PartialView("_DevIdDetails", vm); + } + + /// + /// The update the dev Id details. + /// + /// The model. + /// The . + [HttpPost] + public async Task UpdateDevIdDetails(ResourceVersionDevIdViewModel model) + { + var message = string.Empty; + if (await this.resourceService.DoesDevIdExistsAsync(model.DevId)) + { + message = "Duplicate"; + } + else + { + await this.resourceService.UpdateDevIdDetailsAsync(model); + message = "Success"; + } + + return this.RedirectToAction("Details", new { id = model.ResourceVersionId, activeTab = "devId", status = message }); + } + /// /// The Index. /// @@ -288,11 +345,17 @@ public async Task TransferResourceOwnership(int resourceId, strin [HttpPost] public async Task Unpublish(int resourceVersionId, string details) { + var associatedFile = await this.resourceService.GetResourceVersionExtendedViewModelAsync(resourceVersionId); var vr = await this.resourceService.UnpublishResourceVersionAsync(resourceVersionId, details); await this.resourceService.CreateResourceVersionEvent(resourceVersionId, Nhs.Models.Enums.ResourceVersionEventTypeEnum.UnpublishedByAdmin, "Unpublish using Admin UI", 0); if (vr.IsValid) { + if (associatedFile.ScormDetails != null || associatedFile.HtmlDetails != null) + { + _ = Task.Run(async () => { await this.fileService.PurgeResourceFile(associatedFile, null); }); + } + return this.Json(new { success = true, @@ -309,6 +372,41 @@ public async Task Unpublish(int resourceVersionId, string details } } + /// + /// The GetAVUnavailableView. + /// + /// partial view. + [Route("Resource/GetAVUnavailableView")] + [HttpGet("GetAVUnavailableView")] + public IActionResult GetAVUnavailableView() + { + return this.PartialView("_AudioVideoUnavailable"); + } + + /// + /// The GetAddAVFlag. + /// + /// Return AV Flag. + [Route("Resource/GetAddAVFlag")] + [HttpGet("GetAddAVFlag")] + public bool GetAddAVFlag() => this.featureManager.IsEnabledAsync(FeatureFlags.AddAudioVideo).Result; + + /// + /// The GetDisplayAVFlag. + /// + /// Return display AV flag. + [Route("Resource/GetDisplayAVFlag")] + [HttpGet("GetDisplayAVFlag")] + public bool GetDisplayAVFlag() => this.featureManager.IsEnabledAsync(FeatureFlags.DisplayAudioVideo).Result; + + /// + /// The GetMKPlayerKey. + /// + /// Mediakind MK Player Key. + [Route("Resource/GetMKPlayerKey")] + [HttpGet("GetMKPlayerKey")] + public string GetMKPlayerKey() => this.websettings.Value.MediaKindSettings.MKPlayerLicence; + private static List FilterOptions() { List options = new List(); diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/UserGroupController.cs b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/UserGroupController.cs index 3a27787f4..1be7f211c 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/UserGroupController.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/UserGroupController.cs @@ -257,6 +257,7 @@ public async Task AddUsersToUserGroup(int userGroupId, string use var vr = await this.userGroupService.AddUsersToUserGroup(userGroupId, userIdList); if (vr.IsValid) { + this.ClearUserCachedPermissions(userIdList); return this.Json(new { success = true, @@ -527,5 +528,16 @@ public async Task UserGroupCatalogues(int id) return this.PartialView("_UserGroupCatalogues", catalogues); } + + private void ClearUserCachedPermissions(string userIdList) + { + if (!string.IsNullOrWhiteSpace(userIdList)) + { + foreach (var userId in userIdList.Split(",")) + { + _ = Task.Run(async () => { await this.userService.ClearUserCachedPermissions(int.Parse(userId)); }); + } + } + } } } \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/api/MediaManifestProxyController.cs b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/api/MediaManifestProxyController.cs index 2cf327f6e..f89aa6c3f 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/api/MediaManifestProxyController.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/api/MediaManifestProxyController.cs @@ -51,16 +51,27 @@ public string Get(string playBackUrl, string token) { using (var reader = new StreamReader(stream)) { - const string qualityLevelRegex = @"(QualityLevels\(\d+\))"; + const string qualityLevelRegex = @"(|)([^""\s]+\.m3u8\(encryption=cbc\))"; const string fragmentsRegex = @"(Fragments\([\w\d=-]+,[\w\d=-]+\))"; - const string urlRegex = @"("")(https?:\/\/[\da-z\.-]+\.[a-z\.]{2,6}[\/\w \.-]*\/?[\?&][^&=]+=[^&=#]*)("")"; + const string urlRegex = @"(https?:\/\/[\da-z\.-]+\.[a-z\.]{2,6}[\/\w \.-]*\?[^,\s""]*)"; var baseUrl = playBackUrl.Substring(0, playBackUrl.IndexOf(".ism", System.StringComparison.OrdinalIgnoreCase)) + ".ism"; this.logger.LogDebug($"baseUrl={baseUrl}"); var content = reader.ReadToEnd(); - var newContent = Regex.Replace(content, urlRegex, string.Format(CultureInfo.InvariantCulture, "$1$2&token={0}$3", token)); + content = ReplaceUrisWithProxy(content, baseUrl); + var newContent = Regex.Replace(content, urlRegex, match => + { + string baseUrlWithQuery = match.Groups[1].Value; // URL including the query string + + // Append the token correctly without modifying surrounding characters + string newUrl = baseUrlWithQuery.Contains("?") ? + $"{baseUrlWithQuery}&token={token}" : + $"{baseUrlWithQuery}?token={token}"; + + return newUrl; + }); this.logger.LogDebug($"newContent={newContent}"); var match = Regex.Match(playBackUrl, qualityLevelRegex); @@ -87,5 +98,33 @@ public string Get(string playBackUrl, string token) return null; } + + private static string ReplaceUrisWithProxy(string playlistContent, string proxyUrl) + { + // Split the playlist content into lines + var lines = playlistContent.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + + // Process each line to replace media or map URIs + for (int i = 0; i < lines.Length; i++) + { + if (lines[i].StartsWith("#EXT-X-MAP:URI=", StringComparison.OrdinalIgnoreCase)) + { + // Extract the URI from the current line for EXT-X-MAP + var existingUri = lines[i].Substring(lines[i].IndexOf('=') + 1).Trim('"'); + var newUri = $"{proxyUrl}/{existingUri}"; + lines[i] = lines[i].Replace(existingUri, newUri); + } + else if (lines[i].StartsWith("#EXTINF:", StringComparison.OrdinalIgnoreCase) && i + 1 < lines.Length) + { + // Get the URI from the next line for EXTINF + var existingUri = lines[i + 1].Trim(); + var newUri = $"{proxyUrl}/{existingUri}"; + lines[i + 1] = newUri; + } + } + + // Join the modified lines back into a single string + return string.Join("\r\n", lines); + } } } diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Helpers/FeatureFlags.cs b/AdminUI/LearningHub.Nhs.AdminUI/Helpers/FeatureFlags.cs new file mode 100644 index 000000000..24e9e3be9 --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Helpers/FeatureFlags.cs @@ -0,0 +1,18 @@ +namespace LearningHub.Nhs.AdminUI.Helpers +{ + /// + /// . + /// + public static class FeatureFlags + { + /// + /// The AddAudioVideo. + /// + public const string AddAudioVideo = "AddAudioVideo"; + + /// + /// The DisplayAudioVideo. + /// + public const string DisplayAudioVideo = "DisplayAudioVideo"; + } +} diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Helpers/LearningActivityHelper.cs b/AdminUI/LearningHub.Nhs.AdminUI/Helpers/LearningActivityHelper.cs index b7646146e..caffaaa5d 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Helpers/LearningActivityHelper.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Helpers/LearningActivityHelper.cs @@ -116,6 +116,12 @@ public static string GetResourceTypeVerb(this MyLearningDetailedItemViewModel my return "Played " + GetDurationText(myLearningDetailedItemViewModel.ActivityDurationSeconds * 1000); case ResourceTypeEnum.WebLink: return "Visited"; + case ResourceTypeEnum.Html: + return "Viewed"; + case ResourceTypeEnum.Case: + return "Accessed"; + case ResourceTypeEnum.Assessment: + return "Accessed"; default: return string.Empty; } diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IFileService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IFileService.cs index 3d8a80b92..ef7ccfcde 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IFileService.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IFileService.cs @@ -1,38 +1,48 @@ namespace LearningHub.Nhs.AdminUI.Interfaces { - using System.IO; - using System.Threading.Tasks; - using Azure.Storage.Files.Shares.Models; + using System.Collections.Generic; + using System.IO; + using System.Threading.Tasks; + using Azure.Storage.Files.Shares.Models; + using LearningHub.Nhs.Models.Resource; - /// - /// Defines the . - /// - public interface IFileService - { /// - /// The DeleteChunkDirectory. + /// Defines the . /// - /// Directory ref. - /// Chunks. - /// A representing the result of the asynchronous operation. - Task DeleteChunkDirectory(string directoryRef, int chunks); + public interface IFileService + { + /// + /// The DeleteChunkDirectory. + /// + /// Directory ref. + /// Chunks. + /// A representing the result of the asynchronous operation. + Task DeleteChunkDirectory(string directoryRef, int chunks); - /// - /// The DownloadFileAsync. - /// - /// File path. - /// File name. - /// A representing the result of the asynchronous operation. - Task DownloadFileAsync(string filePath, string fileName); + /// + /// The DownloadFileAsync. + /// + /// File path. + /// File name. + /// A representing the result of the asynchronous operation. + Task DownloadFileAsync(string filePath, string fileName); - /// - /// The ProcessFile. - /// - /// The fileBytes. - /// The fileName. - /// The directoryRef. - /// The originalFileName. - /// The . - Task ProcessFile(Stream fileBytes, string fileName, string directoryRef = "", string originalFileName = null); - } + /// + /// The ProcessFile. + /// + /// The fileBytes. + /// The fileName. + /// The directoryRef. + /// The originalFileName. + /// The . + Task ProcessFile(Stream fileBytes, string fileName, string directoryRef = "", string originalFileName = null); + + /// + /// The PurgeResourceFile. + /// + /// The vm.. + /// . + /// The . + Task PurgeResourceFile(ResourceVersionExtendedViewModel vm = null, List filePaths = null); + } } diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IResourceService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IResourceService.cs index 826c98b55..703be694e 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IResourceService.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IResourceService.cs @@ -32,9 +32,30 @@ public interface IResourceService /// The GetResourceVersionValidationResultAsync. /// /// The resourceVersionId. - /// The . + /// The . Task GetResourceVersionValidationResultAsync(int resourceVersionId); + /// + /// The GetResourceVersionDevIdDetailsAsync. + /// + /// The resourceVersionId. + /// The . + Task GetResourceVersionDevIdDetailsAsync(int resourceVersionId); + + /// + /// Check dev id already exist against a resource. + /// + /// string devId. + /// The . + Task DoesDevIdExistsAsync(string devId); + + /// + /// To update dev id details for a resource. + /// + /// the ResourceVersionDevIdViewModel. + /// The . + Task UpdateDevIdDetailsAsync(ResourceVersionDevIdViewModel model); + /// /// The GetResourceVersionExtendedViewModelAsync. /// diff --git a/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj b/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj index cd2e8ab82..3eb1d8323 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj +++ b/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj @@ -89,11 +89,13 @@ - + + + @@ -103,7 +105,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + + diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/MKPlayerConfigEnum.ts b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/MKPlayerConfigEnum.ts new file mode 100644 index 000000000..6794155b3 --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/MKPlayerConfigEnum.ts @@ -0,0 +1,14 @@ +export enum MKPlayerType { + Html5 = "html5", + Native = "native", + WebRtc = "webrtc", + Unknown = "unknown" +} +export enum MKStreamType { + Hls = "hls", + Dash = "dash", + Progressive = "progressive", + Smooth = "smooth", + Whep = "whep", + Unknown = "unknown" +}; diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/ckeditorwithhint.vue b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/ckeditorwithhint.vue index a31005950..824b025cd 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/ckeditorwithhint.vue +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/ckeditorwithhint.vue @@ -23,7 +23,10 @@ }, data() { return { - editorConfig: { toolbar: CKEditorToolbar.default }, + editorConfig: { + toolbar: CKEditorToolbar.default, + versionCheck: false + }, description: this.initialValue, hint: `You have ${this.maxLength} characters remaining`, valid: getRemainingCharactersFromHtml(this.maxLength, this.initialValue) >= 0, diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content-structure/contentStructure.vue b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content-structure/contentStructure.vue index c16eb9d59..6b9041142 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content-structure/contentStructure.vue +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content-structure/contentStructure.vue @@ -169,7 +169,10 @@ export default Vue.extend({ isReady: false, EditModeEnum: EditModeEnum, HierarchyEditStatusEnum: HierarchyEditStatusEnum, - editorConfig: { toolbar: CKEditorToolbar.default }, + editorConfig: { + toolbar: CKEditorToolbar.default, + versionCheck: false + }, deleteFolderName: '', editFolderStructureButtonText: '', editFolderStructureButtonDisabled: true, diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/cmsPageRow.vue b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/cmsPageRow.vue index 488402b62..290f00605 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/cmsPageRow.vue +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/cmsPageRow.vue @@ -10,7 +10,6 @@

{{pageSectionDetail.sectionTitle}}

-
@@ -65,6 +69,10 @@ import { PageSectionDetailModel, SectionLayoutType, } from '../models/content/pageSectionDetailModel'; import { contentData } from '../data/content'; import { AzureMediaAssetModel } from '../models/content/videoAssetModel'; + import { MKPlayer } from '@mediakind/mkplayer'; + import { MKPlayerType, MKStreamType } from '../MKPlayerConfigEnum'; + //import { getPlayerConfig, getSourceConfig, initializePlayer } from '../mkiomediaplayer'; + import { buildControlbar } from '../mkioplayer-controlbar'; export default Vue.extend({ props: { @@ -78,10 +86,24 @@ SectionTemplateType: SectionTemplateType, pageSectionDetail: null as PageSectionDetailModel, disableVideoControl: false, + displayAVFlag: false, + audioVideoUnavailableView: '' as string, + player: null, + videoContainer: null, + mkioKey: '', + isIphone: false, + requestURL: '' }; }, - created() { + async created(): Promise { + await this.getMKIOPlayerKey(); this.load(); + this.getDisplayAVFlag(); + this.getAudioVideoUnavailableView(); + }, + mounted() { + this.checkIfIphone(); + this.requestURL = window.location.origin; }, computed: { getStyle() { @@ -125,7 +147,7 @@ returnClass = "information-page__text-container--no-padding-right"; } } - return returnClass; + return returnClass; }, getDescription() { if (this.section.description) { @@ -138,56 +160,168 @@ }, isRightSectionLayout() { return this.section.sectionLayoutType == SectionLayoutType.Right; - } + }, + getPlayerUniqueId(): string { + return `videoContainer_${this.section.id}` + }, }, methods: { + getDisplayAVFlag() { + contentData.getDisplayAVFlag().then(response => { + this.displayAVFlag = response; + }); + }, + getAudioVideoUnavailableView() { + contentData.getAVUnavailableView().then(response => { + this.audioVideoUnavailableView = response; + }); + }, + //onSubtitleAdded() { + // this.player.subtitles.enable("subtitle" + this.section.id.toString()); + //}, + onPlayerReady() { + var contanierId = this.section.id.toString(); + var uniquePlayer = this.player;// (player_@Model.Id); + buildControlbar(contanierId, uniquePlayer); + + // [BY] When we set UI to false we need to manually add the controls to the video element + //const videoElement = document.getElementById("bitmovinplayer-video-" + this.getPlayerUniqueId) as HTMLVideoElement; + //if (videoElement) { + // videoElement.controls = true; + //} + + // var subtitleTrack; + //if (this.pageSectionDetail.videoAsset.azureMediaAsset && this.pageSectionDetail.videoAsset.closedCaptionsFile) { + // const captionsInfo = this.pageSectionDetail.videoAsset.closedCaptionsFile; + // var srcPath = "file/download/" + captionsInfo.filePath + "/" + captionsInfo.fileName; + // //srcPath = '@requestURL' + srcPath; + // srcPath = "https://bitdash-a.akamaihd.net/content/sintel/subtitles/subtitles_en.vtt"; + + // subtitleTrack = { + // id: "subtitle" + this.section.id.toString(), + // lang: "en", + // label: "english", + // url: srcPath, + // kind: "subtitle" + // }; + //}; + + //this.player.addSubtitle(subtitleTrack); + }, + async getMKIOPlayerKey(): Promise { + this.mkioKey = await contentData.getMKPlayerKey(); + //return this.mkioKey; + }, load() { if (this.sectionTemplateType === SectionTemplateType.Video) { - contentData.getPageSectionDetailVideo(this.section.id).then(response => { - this.pageSectionDetail = response; + contentData.getPageSectionDetailVideo(this.section.id).then(response => { + this.pageSectionDetail = response; - if (!this.pageSectionDetail.videoAsset) - return; + if (!this.pageSectionDetail.videoAsset) + return; - const id = 'azureMediaPlayer' + this.pageSectionDetail.id; - let azureMediaPlayer = amp(id); + // Grab the video container + this.videoContainer = document.getElementById(this.getPlayerUniqueId); - if (this.pageSectionDetail.videoAsset.azureMediaAsset) { - $(`#${id}`).css({ 'height': '', 'border': '1px solid #768692' }); - this.disableVideoControl = false; - } else { - this.disableVideoControl = true; - } + if (!this.mkioKey) { + this.getMKIOPlayerKey(); + } - if (this.pageSectionDetail.videoAsset.thumbnailImageFile) { - azureMediaPlayer.poster(`/file/download/${this.pageSectionDetail.videoAsset.thumbnailImageFile.filePath}/${this.pageSectionDetail.videoAsset.thumbnailImageFile.fileName}`); - } - if (this.pageSectionDetail.videoAsset.azureMediaAsset && this.pageSectionDetail.videoAsset.closedCaptionsFile) { - azureMediaPlayer.src([{ - type: "application/vnd.ms-sstr+xml", - src: this.pageSectionDetail.videoAsset.azureMediaAsset.locatorUri, - protectionInfo: [{ type: 'AES', authenticationToken: `Bearer=${this.pageSectionDetail.videoAsset.azureMediaAsset.authenticationToken}` }] - }], - [{ kind: "captions", src: `/file/download/${this.pageSectionDetail.videoAsset.closedCaptionsFile.filePath}/${this.pageSectionDetail.videoAsset.closedCaptionsFile.fileName}`, srclang: "en", label: "english" }]); - } - else if (this.pageSectionDetail.videoAsset.azureMediaAsset && !this.pageSectionDetail.videoAsset.closedCaptionsFile) { - azureMediaPlayer.src([{ - type: "application/vnd.ms-sstr+xml", - src: this.pageSectionDetail.videoAsset.azureMediaAsset.locatorUri, - protectionInfo: [{ type: 'AES', authenticationToken: `Bearer=${this.pageSectionDetail.videoAsset.azureMediaAsset.authenticationToken}` }] - }]); - } - }); - } else { - contentData.getPageSectionDetail(this.section.id).then(x => this.pageSectionDetail = x); - } + // Prepare the player configuration + const playerConfig = { + key: this.mkioKey, + ui: true, + playback: { + muted: false, + autoplay: false, + preferredTech: [{ player: MKPlayerType.Html5, streaming: MKStreamType.Hls }] // to force the player to use html5 player instead of native on safari + }, + theme: "dark", + events: { + ready: this.onPlayerReady, + //subtitleadded: this.onSubtitleAdded, + } + }; + + // Initialize the player with video container and player configuration + this.player = new MKPlayer(this.videoContainer, playerConfig); + + var subtitleTrack = null; + var sectionId = this.section.id.toString(); + if (this.pageSectionDetail.videoAsset.azureMediaAsset && this.pageSectionDetail.videoAsset.closedCaptionsFile) { + var captionsInfo = this.pageSectionDetail.videoAsset.closedCaptionsFile;; + + if (captionsInfo) { + var srcPath = "/file/download/" + captionsInfo.filePath + "/" + captionsInfo.fileName; + subtitleTrack = { + id: "subtitle" + sectionId, + lang: "en", + label: "english", + url: this.requestURL + srcPath, + kind: "subtitle" + }; + } + } + // Load source + const sourceConfig = { + hls: this.getMediaPlayUrl(this.pageSectionDetail.videoAsset.azureMediaAsset.locatorUri), + subtitleTracks: [subtitleTrack], + drm: { + clearkey: { + LA_URL: "HLS_AES", + headers: { + "Authorization": this.getBearerToken(this.pageSectionDetail.videoAsset.azureMediaAsset.authenticationToken) + } + } + } + }; + + this.player.load(sourceConfig) + .then(() => { + console.log("Source loaded successfully!"); + }) + .catch(() => { + console.error("An error occurred while loading the source!"); + }); + + //const id = 'azureMediaPlayer' + this.pageSectionDetail.id; + //let azureMediaPlayer = amp(id); + + //if (this.pageSectionDetail.videoAsset.azureMediaAsset) { + // $(`#${id}`).css({ 'height': '', 'border': '1px solid #768692' }); + // this.disableVideoControl = false; + //} else { + // this.disableVideoControl = true; + //} + + //if (this.pageSectionDetail.videoAsset.thumbnailImageFile) { + // azureMediaPlayer.poster(`/file/download/${this.pageSectionDetail.videoAsset.thumbnailImageFile.filePath}/${this.pageSectionDetail.videoAsset.thumbnailImageFile.fileName}`); + //} + //if (this.pageSectionDetail.videoAsset.azureMediaAsset && this.pageSectionDetail.videoAsset.closedCaptionsFile) { + // azureMediaPlayer.src([{ + // type: "application/vnd.ms-sstr+xml", + // src: this.pageSectionDetail.videoAsset.azureMediaAsset.locatorUri, + // protectionInfo: [{ type: 'AES', authenticationToken: `Bearer=${this.pageSectionDetail.videoAsset.azureMediaAsset.authenticationToken}` }] + // }], + // [{ kind: "captions", src: `/file/download/${this.pageSectionDetail.videoAsset.closedCaptionsFile.filePath}/${this.pageSectionDetail.videoAsset.closedCaptionsFile.fileName}`, srclang: "en", label: "english" }]); + //} + //else if (this.pageSectionDetail.videoAsset.azureMediaAsset && !this.pageSectionDetail.videoAsset.closedCaptionsFile) { + // azureMediaPlayer.src([{ + // type: "application/vnd.ms-sstr+xml", + // src: this.pageSectionDetail.videoAsset.azureMediaAsset.locatorUri, + // protectionInfo: [{ type: 'AES', authenticationToken: `Bearer=${this.pageSectionDetail.videoAsset.azureMediaAsset.authenticationToken}` }] + // }]); + //} + }); + } else { + contentData.getPageSectionDetail(this.section.id).then(x => this.pageSectionDetail = x); + } }, getAESProtection(token: string): string { var aesProtectionInfo = '{"protectionInfo": [{"type": "AES", "authenticationToken":"Bearer=' + token + '"}], "streamingFormats":["SMOOTH","DASH"]}'; return aesProtectionInfo; }, getMediaAssetProxyUrl(azureMediaAsset: AzureMediaAssetModel): string { - let playBackUrl = azureMediaAsset.locatorUri; playBackUrl = playBackUrl.substring(0, playBackUrl.lastIndexOf("manifest")) + "manifest(format=m3u8-aapl)"; @@ -195,6 +329,23 @@ return sourceUrl; }, + getBearerToken(token: string): string { + return "Bearer=" + token; + }, + getMediaPlayUrl(url: string): string { + let sourceUrl = url.substring(0, url.lastIndexOf("manifest")) + "manifest(format=m3u8-cmaf,encryption=cbc)"; + + if (this.isIphone) { + var token = this.pageSectionDetail.videoAsset.azureMediaAsset.authenticationToken; + sourceUrl = "/Media/MediaManifest?playBackUrl=" + sourceUrl + "&token=" + token; + } + + return sourceUrl; + }, + checkIfIphone() { + const userAgent = navigator.userAgent || navigator.vendor; + this.isIphone = /iPhone/i.test(userAgent); + }, }, watch: { section() { @@ -208,4 +359,29 @@ pointer-events: none; opacity: 0.5; } + + .video-container { + height: 0; + width: 100%; + overflow: hidden; + position: relative; + padding-top: 56.25%; /* 16:9 aspect ratio */ + background-color: #000; + } + + video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + video[id^="bitmovinplayer-video"] { + width: 100%; + } + + .bmpui-ui-controlbar .control-right { + float: right; + } \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/contentState.ts b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/contentState.ts index af39cb191..32777ec0b 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/contentState.ts +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/contentState.ts @@ -25,7 +25,8 @@ export class State { isVideoFileValid: boolean; isTranscriptFileValid: boolean; isCaptionFileValid: boolean; - isThumbnailFileValid: boolean; + isThumbnailFileValid: boolean; + getAVUnavailableView: string = ''; } const state = new State(); @@ -37,7 +38,10 @@ class ApiRequest { const mutations = { async populateUploadSettings(state: State) { state.uploadSettings = await contentData.getUploadSettings(); - }, + }, + async populateAVUnavailableView(state: State) { + state.getAVUnavailableView = await contentData.getAVUnavailableView(); + }, setCurrentUserName(state: State, payload: string) { state.currentUserName = payload; }, diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageImageSection.vue b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageImageSection.vue index 432da81ae..ee6f0334f 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageImageSection.vue +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageImageSection.vue @@ -257,6 +257,7 @@ SectionLayoutType: SectionLayoutType, editorConfig: { toolbar: CKEditorToolbar.landingPages, + versionCheck: false, stylesSet: 'landing-pages-image-text' }, diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageSectionToolbar.vue b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageSectionToolbar.vue index d091b8081..4d1faf9b0 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageSectionToolbar.vue +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageSectionToolbar.vue @@ -2,7 +2,7 @@
-
+
{{ contentLib.getPageSectionStatusText(this.pageSection.pageSectionDetail) }}
@@ -167,8 +167,8 @@ methods: { getStatusStyle(): string { return this.pageSection.pageSectionDetail.pageSectionStatus === PageSectionStatus.Live - ? 'background: #007F3B; height: 45px; margin: -8px;border: 1px solid #FFFFFF;' - : ((this.pageSection.pageSectionDetail.deletePending == null || !this.pageSection.pageSectionDetail.deletePending) ? 'background: #FFB81C; height: 45px; margin: -8px;border: 1px solid #FFFFFF;color: #425563;' : 'background: #DA291C; height: 45px; margin: -8px; border: 1px solid #FFFFFF;'); + ? 'background: #007F3B; height: 50px; margin: -12px;border: 1px solid #FFFFFF;' + : ((this.pageSection.pageSectionDetail.deletePending == null || !this.pageSection.pageSectionDetail.deletePending) ? 'background: #FFB81C; height: 50px; margin: -12px;border: 1px solid #FFFFFF;color: #425563;' : 'background: #DA291C; height: 50px; margin: -12px; border: 1px solid #FFFFFF;'); }, getStatusIconStyle(): string { return 'margin-top:20px;' @@ -215,8 +215,9 @@ .toolBarBox { position: absolute; - right: 20px; - top: 20px; + right: 12px; + left:10px; + top: 2px; z-index: 1; } @@ -237,7 +238,7 @@ .toolBar { display: flex; padding: 16px; - height: 64px; + height: 60px; float: right; background: $nhsuk-grey; border: 2px solid $nhsuk-white; @@ -269,4 +270,14 @@ .toolBarButton i .fa, .fas { font-weight: 100; } + @media (max-width: 420px) { + .toolBarBox { + position: absolute; + right: 12px; + left: 10px; + top: 2px; + z-index: 1; + width: fit-content; + } + } \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageVideoSection.vue b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageVideoSection.vue index 216daecd7..77d38adb6 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageVideoSection.vue +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageVideoSection.vue @@ -66,7 +66,12 @@
-
+
+ +
+
+ +
@@ -258,6 +263,7 @@ Vue.use(Vuelidate as any); SectionLayoutType: SectionLayoutType, editorConfig: { toolbar: CKEditorToolbar.landingPages, + versionCheck: false, stylesSet: 'landing-pages-video-text' }, @@ -284,7 +290,8 @@ Vue.use(Vuelidate as any); deleteWarning: false, fileDeleteWarning: false, fileOrTypeToBeDeleted: 0, - videoErrorMessage: '' + videoErrorMessage: '', + addAVFlag: false } }, validations: { @@ -294,7 +301,9 @@ Vue.use(Vuelidate as any); } }, async created() { - this.$store.commit('populateUploadSettings'); + this.$store.commit('populateUploadSettings'); + this.$store.commit('populateAVUnavailableView'); + this.getAddAudioVideoFlag(); const pageSectionId = this.$route.params.sectionId; @@ -348,7 +357,10 @@ Vue.use(Vuelidate as any); }, videoAsset(): VideoAssetModel { return this.$store.state.pageSectionDetail.videoAsset; - } + }, + audioVideoUnavailableView(): string { + return this.$store.state.getAVUnavailableView; + }, }, methods: { setSectionLayoutType(sectionLayoutType: SectionLayoutType) { @@ -492,7 +504,12 @@ Vue.use(Vuelidate as any); this.fileErrorType = FileErrorTypeEnum.NoError this.fileUploadServerError = ''; $('#fileUpload').val(null); - }, + }, + getAddAudioVideoFlag() { + contentData.getAddAVFlag().then(response => { + this.addAVFlag = response; + }); + }, }, }); diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/upload/fileValidation.ts b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/upload/fileValidation.ts index 785665430..a950f3a21 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/upload/fileValidation.ts +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/upload/fileValidation.ts @@ -8,7 +8,7 @@ export const file_size_validation = (value: any) => { export const file_extension_validation = (value: any) => { if (!value) { return true; } let fileExtension = value.name.split(".").pop(); - let fileType = ['mp4', 'avi', 'm4v', 'mov', 'mkv', 'mpg', 'mpeg', 'wmv'].find(ext => ext == fileExtension); + let fileType = ['mp4', 'avi', 'm4v', 'mov', 'mkv', 'mpg', 'm2v', 'vob','wmv'].find(ext => ext == fileExtension); return fileType != undefined; }; export const transcriptfile_extension_validation = (value: any) => { diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/data/content.ts b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/data/content.ts index e5d06dbf3..c4d389f3e 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/data/content.ts +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/data/content.ts @@ -241,6 +241,49 @@ const updateVideoAsset = async function (videoAsset: VideoAssetModel): Promise { + return await axios.get('/Resource/GetAddAVFlag') + .then(response => { + return response.data; + }) + .catch(e => { + console.log('getAddAVFlag:' + e); + throw e; + }); +}; + +const getDisplayAVFlag = async function (): Promise { + return await axios.get('/Resource/GetDisplayAVFlag') + .then(response => { + return response.data; + }) + .catch(e => { + console.log('getDisplayAVFlag:' + e); + throw e; + }); +}; + +const getAVUnavailableView = async function (): Promise { + return await axios.get('/Resource/GetAVUnavailableView') + .then(response => { + return response.data; + }) + .catch(e => { + console.error('Error fetching shared partial view:', e) + throw e; + }); +}; +const getMKPlayerKey = async function (): Promise { + return await axios.get('/Resource/GetMKPlayerKey') + .then(response => { + return response.data; + }) + .catch(e => { + console.error('Error fetching Media Kind MKPlayer Key', e) + throw e; + }); +}; + export const contentData = { getUploadSettings, @@ -260,5 +303,9 @@ export const contentData = { createPageSection, updatePageSectionDetail, getPageSectionDetailVideo, - updateVideoAsset + updateVideoAsset, + getAddAVFlag, + getDisplayAVFlag, + getAVUnavailableView, + getMKPlayerKey }; \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/mkiomediaplayer.ts b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/mkiomediaplayer.ts new file mode 100644 index 000000000..988dbdace --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/mkiomediaplayer.ts @@ -0,0 +1,101 @@ +import { MKPlayer, MKPlayerConfig } from '@mediakind/mkplayer'; +import { MKPlayerType, MKStreamType } from './MKPlayerConfigEnum'; +interface ClearKeyConfig { + LA_URL: string; + headers: { + Authorization: string; + }; +} + +interface PlayerConfig { + key: string; + ui: boolean; + playback: { + muted: boolean; + autoplay: boolean; + preferredTech: Array<{ player: string; streaming: string }>; + }; + theme: string; + events: { + ready: () => void; + }; +} + +interface SourceConfig { + hls: string; + drm: { + clearkey: ClearKeyConfig; + }; +} + +function getBearerToken(authenticationToken: string): string { + // Replace this with your actual logic to get the bearer token + return `Bearer ${authenticationToken}`; +} + +function getPlayerConfig( + mkioKey: string, + onPlayerReady: () => void +): PlayerConfig { + return { + key: mkioKey, + ui: true, + playback: { + muted: false, + autoplay: false, + preferredTech: [{ player: "Html5", streaming: "Hls" }] // Adjust these strings if you have specific types + }, + theme: "dark", + events: { + ready: onPlayerReady, + } + }; +} + +function getSourceConfig( + locatorUri: string, + authenticationToken: string +): SourceConfig { + return { + hls: locatorUri, + drm: { + clearkey: { + LA_URL: "HLS_AES", + headers: { + Authorization: getBearerToken(authenticationToken) + } + } + } + }; +} + +function initializePlayer(videoContainer: HTMLElement, playerConfig: MKPlayerConfig, playBackUrl: string, bearerToken: string): any { + const player = new MKPlayer(videoContainer, playerConfig); + + var clearKeyConfig = { + //LA_URL: "https://ottapp-appgw-amp.prodc.mkio.tv3cloud.com/drm/clear-key?ownerUid=azuki", + LA_URL: "HLS_AES", + headers: { + "Authorization": bearerToken + } + }; + + const sourceConfig: SourceConfig = { + hls: playBackUrl, + drm: { + clearkey: clearKeyConfig + } + }; + + player.load(sourceConfig) + .then(() => { + console.log("Source loaded successfully!"); + }) + .catch(() => { + console.error("An error occurred while loading the source!"); + }); + + return player; +}; + +export { getPlayerConfig, getSourceConfig, initializePlayer }; diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/mkioplayer-controlbar.ts b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/mkioplayer-controlbar.ts new file mode 100644 index 000000000..a9db5fb13 --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/mkioplayer-controlbar.ts @@ -0,0 +1,77 @@ +/** + * Constructs and configures the control bar for the UI. + * + * This function performs the following tasks: + * 1. Selects the titlebar and controlbar elements from the DOM. + * 2. Creates a playback toggle button with an initial "Play" state and appends it to the controlbar. + * 3. Adds an event listener to the playback toggle button to handle play/pause functionality. + * 4. Retrieves all buttons from the titlebar, aligns them to the right (except for the "Mute" button), and appends them to the controlbar. + * 5. Selects the UI container element and sets up a MutationObserver to monitor changes in the container's class attribute. + * 6. Updates the playback toggle button state based on the player's state (playing or paused) when the container's class changes. + */ + +function buildControlbar(id: string, player: { isPlaying: () => boolean; pause: () => void; play: () => void; }): void { + const mediacontainerId = 'videoContainer_' + id; + + // Select the titlebar and controlbar elements from the DOM + const titlebar = document.querySelector(`#${mediacontainerId} .bmpui-ui-titlebar`) as HTMLElement; + const controlbar = document.querySelector(`#${mediacontainerId} .bmpui-ui-controlbar`) as HTMLElement; + + // Check if both titlebar and controlbar elements exist + if (titlebar && controlbar) { + + // Create a playback toggle button and set its initial state and appearance + const playbackToggleButton = document.createElement('button'); + playbackToggleButton.classList.add('bmpui-ui-playbacktogglebutton', 'bmpui-off'); + playbackToggleButton.setAttribute('aria-label', 'Play'); + playbackToggleButton.innerHTML = 'Play'; + playbackToggleButton.id = 'playback-toggle-btn-' + id; + controlbar.appendChild(playbackToggleButton); + + // Add an event listener to the playback toggle button + playbackToggleButton.addEventListener('click', function () { + // Toggle playback state based on the current state + if (player.isPlaying()) { + player.pause(); + playbackToggleButton.classList.remove('bmpui-on'); + playbackToggleButton.classList.add('bmpui-off'); + playbackToggleButton.innerHTML = 'Play'; + } else { + player.play(); + playbackToggleButton.classList.remove('bmpui-off'); + playbackToggleButton.classList.add('bmpui-on'); + playbackToggleButton.innerHTML = 'Pause'; + } + }); + + // Get all button elements from the titlebar + const buttons = titlebar.querySelectorAll('button'); + + // Reverse the button list and append each button to the controlbar + Array.from(buttons).reverse().forEach(button => { + if (button.textContent !== "Mute") { + button.classList.add('control-right'); // Add a class to align buttons to the right + } + controlbar.appendChild(button); // Append the button to the controlbar + }); + + // Select the UI container element + const uiOverlayElement = document.querySelector(`#${mediacontainerId} .bmpui-ui-playbacktoggle-overlay`) as HTMLElement; + uiOverlayElement.addEventListener('click', function () { + const uiContainerElement = document.querySelector(`#${mediacontainerId} .bmpui-ui-uicontainer`) as HTMLElement; + // Update the playback toggle button state based on the player's state + if (uiContainerElement.classList.contains('bmpui-player-state-playing')) { + playbackToggleButton.classList.remove('bmpui-on'); + playbackToggleButton.classList.add('bmpui-off'); + } else { + playbackToggleButton.classList.remove('bmpui-off'); + playbackToggleButton.classList.add('bmpui-on'); + } + }); + + } else { + console.error('UI container element not found'); + } +} + +export { buildControlbar }; \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/ServiceCollectionExtension.cs b/AdminUI/LearningHub.Nhs.AdminUI/ServiceCollectionExtension.cs index 3b1cd833f..5517fa636 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/ServiceCollectionExtension.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/ServiceCollectionExtension.cs @@ -32,6 +32,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; + using Microsoft.FeatureManagement; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; @@ -94,7 +95,7 @@ public static void ConfigureServices(this IServiceCollection services, IConfigur services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -219,6 +220,8 @@ public static void ConfigureServices(this IServiceCollection services, IConfigur { options.Filters.Add(typeof(CheckInitialLogonFilter)); }); + + services.AddFeatureManagement(); } private static async Task UserSessionBegins(TokenValidatedContext context) diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Services/FileService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Services/FileService.cs index 7998020b6..60a4e0781 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Services/FileService.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Services/FileService.cs @@ -1,122 +1,393 @@ namespace LearningHub.Nhs.AdminUI.Services { - using System; - using System.IO; - using System.Threading.Tasks; - using Azure.Storage.Files.Shares; - using Azure.Storage.Files.Shares.Models; - using LearningHub.Nhs.AdminUI.Configuration; - using LearningHub.Nhs.AdminUI.Interfaces; - using Microsoft.AspNetCore.StaticFiles; - using Microsoft.Extensions.Options; - - /// - /// Defines the . - /// - public class FileService : IFileService - { - private readonly WebSettings settings; - private ShareClient shareClient; + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Threading.Tasks; + using Azure.Storage.Files.Shares; + using Azure.Storage.Files.Shares.Models; + using LearningHub.Nhs.AdminUI.Configuration; + using LearningHub.Nhs.AdminUI.Interfaces; + using LearningHub.Nhs.Models.Resource; + using Microsoft.AspNetCore.StaticFiles; + using Microsoft.Extensions.Options; /// - /// Initializes a new instance of the class. + /// Defines the . /// - /// Settings. - public FileService(IOptions settings) + public class FileService : IFileService { - this.settings = settings.Value; - } + private readonly WebSettings settings; + private ShareClient shareClient; + private ShareClient archiveShareClient; + private ShareClient contentArchiveShareClient; - private ShareClient ShareClient - { - get - { - if (this.shareClient == null) + /// + /// Initializes a new instance of the class. + /// + /// Settings. + public FileService(IOptions settings) + { + this.settings = settings.Value; + } + + private ShareClient ShareClient { - var options = new ShareClientOptions(); - options.Retry.MaxRetries = 3; - options.Retry.Delay = TimeSpan.FromSeconds(10); + get + { + if (this.shareClient == null) + { + var options = new ShareClientOptions(); + options.Retry.MaxRetries = 3; + options.Retry.Delay = TimeSpan.FromSeconds(10); - this.shareClient = new ShareClient(this.settings.AzureFileStorageConnectionString, this.settings.AzureFileStorageResourceShareName, options); + this.shareClient = new ShareClient(this.settings.AzureFileStorageConnectionString, this.settings.AzureFileStorageResourceShareName, options); - if (!this.shareClient.Exists()) - { - throw new Exception($"Unable to access azure file storage resource {this.settings.AzureFileStorageResourceShareName}"); - } + if (!this.shareClient.Exists()) + { + throw new Exception($"Unable to access azure file storage resource {this.settings.AzureFileStorageResourceShareName}"); + } + } + + return this.shareClient; + } } - return this.shareClient; - } - } + private ShareClient OutputArchiveShareClient + { + get + { + if (this.contentArchiveShareClient == null) + { + var options = new ShareClientOptions(); + options.Retry.MaxRetries = 3; + options.Retry.Delay = TimeSpan.FromSeconds(10); - /// - /// The DeleteChunkDirectory. - /// - /// The directoryRef. - /// The chunks. - /// The . - public async Task DeleteChunkDirectory(string directoryRef, int chunks) - { - var directory = this.ShareClient.GetDirectoryClient(directoryRef); + this.contentArchiveShareClient = new ShareClient(this.settings.AzureContentArchiveStorageConnectionString, this.settings.AzureFileStorageResourceShareName, options); + + if (!this.contentArchiveShareClient.Exists()) + { + throw new Exception($"Unable to access azure content archive file storage resource {this.settings.AzureFileStorageResourceShareName}"); + } + } + + return this.contentArchiveShareClient; + } + } - if (await directory.ExistsAsync()) - { - for (int i = 0; i < chunks; i++) + private ShareClient InputArchiveShareClient { - var file = directory.GetFileClient("Chunk_" + i.ToString()); - await file.DeleteIfExistsAsync(); + get + { + if (this.archiveShareClient == null) + { + var options = new ShareClientOptions(); + options.Retry.MaxRetries = 3; + options.Retry.Delay = TimeSpan.FromSeconds(10); + + this.archiveShareClient = new ShareClient(this.settings.AzureSourceArchiveStorageConnectionString, this.settings.AzureFileStorageResourceShareName, options); + + if (!this.archiveShareClient.Exists()) + { + throw new Exception($"Unable to access azure file storage resource {this.settings.AzureFileStorageResourceShareName}"); + } + } + + return this.archiveShareClient; + } } - await directory.DeleteAsync(); - } - } + /// + /// The DeleteChunkDirectory. + /// + /// The directoryRef. + /// The chunks. + /// The . + public async Task DeleteChunkDirectory(string directoryRef, int chunks) + { + var directory = this.ShareClient.GetDirectoryClient(directoryRef); - /// - /// The DownloadFileAsync. - /// - /// The filePath. - /// The fileName. - /// The . - public async Task DownloadFileAsync(string filePath, string fileName) - { - var directory = this.ShareClient.GetDirectoryClient(filePath); + if (await directory.ExistsAsync()) + { + for (int i = 0; i < chunks; i++) + { + var file = directory.GetFileClient("Chunk_" + i.ToString()); + await file.DeleteIfExistsAsync(); + } - if (await directory.ExistsAsync()) - { - var file = directory.GetFileClient(fileName); + await directory.DeleteAsync(); + } + } - if (await file.ExistsAsync()) + /// + /// The DownloadFileAsync. + /// + /// The filePath. + /// The fileName. + /// The . + public async Task DownloadFileAsync(string filePath, string fileName) { - return await file.DownloadAsync(); + var directory = this.ShareClient.GetDirectoryClient(filePath); + var sourceDirectory = this.InputArchiveShareClient.GetDirectoryClient(filePath); + + if (await directory.ExistsAsync()) + { + var file = directory.GetFileClient(fileName); + + if (await file.ExistsAsync()) + { + return await file.DownloadAsync(); + } + } + else if (await sourceDirectory.ExistsAsync()) + { + var file = sourceDirectory.GetFileClient(fileName); + + if (await file.ExistsAsync()) + { + return await file.DownloadAsync(); + } + } + + return null; } - } - return null; - } + /// + public async Task ProcessFile(Stream fileBytes, string fileName, string directoryRef = "", string originalFileName = null) + { + if (directoryRef == string.Empty) + { + directoryRef = Guid.NewGuid().ToString(); + } - /// - public async Task ProcessFile(Stream fileBytes, string fileName, string directoryRef = "", string originalFileName = null) - { - if (directoryRef == string.Empty) - { - directoryRef = Guid.NewGuid().ToString(); - } + var directory = this.ShareClient.GetDirectoryClient(directoryRef); + + await directory.CreateIfNotExistsAsync(); - var directory = this.ShareClient.GetDirectoryClient(directoryRef); + var fileClient = directory.GetFileClient(fileName); - await directory.CreateIfNotExistsAsync(); + if (!new FileExtensionContentTypeProvider().TryGetContentType(originalFileName ?? fileName, out string contentType)) + { + contentType = "application/octet-stream"; + } - var fileClient = directory.GetFileClient(fileName); + await fileClient.CreateAsync(fileBytes.Length, httpHeaders: new ShareFileHttpHeaders { ContentType = contentType }); + await fileClient.UploadAsync(fileBytes); - if (!new FileExtensionContentTypeProvider().TryGetContentType(originalFileName ?? fileName, out string contentType)) - { - contentType = "application/octet-stream"; - } + return directoryRef; + } + + /// + /// The PurgeResourceFile. + /// + /// The vm.. + /// . + /// The . + public async Task PurgeResourceFile(ResourceVersionExtendedViewModel vm = null, List filePaths = null) + { + if (filePaths != null + && filePaths.Any()) + { + await this.MoveInPutDirectoryToArchive(filePaths); + return; + } - await fileClient.CreateAsync(fileBytes.Length, httpHeaders: new ShareFileHttpHeaders { ContentType = contentType }); - await fileClient.UploadAsync(fileBytes); + if (vm != null) + { + var allContentPath = new List(); + var allFilePath = new List(); + if (vm.ScormDetails != null && !string.IsNullOrWhiteSpace(vm.ScormDetails.ContentFilePath)) + { + allContentPath.Add(vm.ScormDetails.ContentFilePath); + } + else if (vm.HtmlDetails != null && !string.IsNullOrWhiteSpace(vm.HtmlDetails.ContentFilePath)) + { + allContentPath.Add(vm.HtmlDetails.ContentFilePath); + } - return directoryRef; + // audio and video to be added + await this.MoveInPutDirectoryToArchive(allFilePath); + await this.MoveOutPutDirectoryToArchive(allContentPath); + } + } + + private static async Task WaitForCopyAsync(ShareFileClient fileClient) + { + // Wait for the copy operation to complete + ShareFileProperties copyInfo; + do + { + await Task.Delay(500); // Delay before checking the status again + copyInfo = await fileClient.GetPropertiesAsync().ConfigureAwait(false); + } + while (copyInfo.CopyStatus == CopyStatus.Pending); + + if (copyInfo.CopyStatus != CopyStatus.Success) + { + throw new InvalidOperationException($"Copy failed: {copyInfo.CopyStatus}"); + } + } + + private async Task MoveOutPutDirectoryToArchive(List allDirectoryRef) + { + try + { + if (allDirectoryRef.Any()) + { + foreach (var directoryRef in allDirectoryRef) + { + var directory = this.ShareClient.GetDirectoryClient(directoryRef); + var archiveDirectory = this.OutputArchiveShareClient.GetDirectoryClient(directoryRef); + + await foreach (var fileItem in directory.GetFilesAndDirectoriesAsync()) + { + var sourceFileClient = directory.GetFileClient(fileItem.Name); + + archiveDirectory.CreateIfNotExists(); + if (fileItem.IsDirectory) + { + if (await archiveDirectory.GetSubdirectoryClient(fileItem.Name).ExistsAsync() == false) + { + archiveDirectory.CreateSubdirectory(fileItem.Name); + } + + await this.MigrateSubdirectory(sourceFileClient.Path); + } + else + { + var destinationFileClient = archiveDirectory.GetFileClient(fileItem.Name); + var uri = sourceFileClient.GenerateSasUri(Azure.Storage.Sas.ShareFileSasPermissions.Read, DateTime.UtcNow.AddHours(24)); + + await destinationFileClient.StartCopyAsync(uri); + + await WaitForCopyAsync(destinationFileClient); + } + } + + if (await directory.ExistsAsync()) + { + foreach (var fileItem in directory.GetFilesAndDirectories()) + { + var sourceFileClient = directory.GetFileClient(fileItem.Name); + if (fileItem.IsDirectory) + { + await this.DeleteSubdirectory(sourceFileClient.Path); + } + else + { + await sourceFileClient.DeleteIfExistsAsync(); + } + } + + await directory.DeleteIfExistsAsync(); + } + } + } + } + catch (Exception ex) + { + } + } + + private async Task MigrateSubdirectory(string pathDirectory) + { + var sourceDirectory = this.ShareClient.GetDirectoryClient(pathDirectory); + var archiveDirectory = this.OutputArchiveShareClient.GetDirectoryClient(pathDirectory); + + await foreach (var fileDirectory in sourceDirectory.GetFilesAndDirectoriesAsync()) + { + if (fileDirectory.IsDirectory) + { + if (await archiveDirectory.GetSubdirectoryClient(fileDirectory.Name).ExistsAsync() == false) + { + archiveDirectory.CreateSubdirectory(fileDirectory.Name); + } + + var sourceFileClient = sourceDirectory.GetFileClient(fileDirectory.Name); + await this.MigrateSubdirectory(sourceFileClient.Path); + } + else + { + var sourceFileClient = sourceDirectory.GetFileClient(fileDirectory.Name); + var destinationFileClient = archiveDirectory.GetFileClient(fileDirectory.Name); + var uri = sourceFileClient.GenerateSasUri(Azure.Storage.Sas.ShareFileSasPermissions.Read, DateTime.UtcNow.AddHours(24)); + await destinationFileClient.StartCopyAsync(uri); + await WaitForCopyAsync(destinationFileClient); + } + } + } + + private async Task DeleteSubdirectory(string pathDirectory) + { + var sourceDirectory = this.ShareClient.GetDirectoryClient(pathDirectory); + + await foreach (var fileDirectory in sourceDirectory.GetFilesAndDirectoriesAsync()) + { + if (fileDirectory.IsDirectory) + { + var sourceFileClient = sourceDirectory.GetFileClient(fileDirectory.Name); + await this.DeleteSubdirectory(sourceFileClient.Path); + await sourceFileClient.DeleteIfExistsAsync(); + } + else + { + var sourceFileClient = sourceDirectory.GetFileClient(fileDirectory.Name); + await sourceFileClient.DeleteIfExistsAsync(); + } + } + + await sourceDirectory.DeleteIfExistsAsync(); + } + + private async Task MoveInPutDirectoryToArchive(List allDirectoryRef) + { + if (allDirectoryRef.Any()) + { + foreach (var directoryRef in allDirectoryRef) + { + var directory = this.ShareClient.GetDirectoryClient(directoryRef); + var archiveDirectory = this.InputArchiveShareClient.GetDirectoryClient(directoryRef); + if (!directory.Exists()) + { + continue; + } + + if (directory.Exists()) + { + var inputCheck = directory.GetFilesAndDirectories(); + if (inputCheck.Count() > 1) + { + await this.MoveOutPutDirectoryToArchive(new List { directoryRef }); + continue; + } + } + + await foreach (var fileItem in directory.GetFilesAndDirectoriesAsync()) + { + var sourceFileClient = directory.GetFileClient(fileItem.Name); + + archiveDirectory.CreateIfNotExists(); + var destinationFileClient = archiveDirectory.GetFileClient(fileItem.Name); + var uri = sourceFileClient.GenerateSasUri(Azure.Storage.Sas.ShareFileSasPermissions.Read, DateTime.UtcNow.AddHours(24)); + + await destinationFileClient.StartCopyAsync(uri); + + await WaitForCopyAsync(destinationFileClient); + } + + if (await directory.ExistsAsync()) + { + foreach (var fileItem in directory.GetFilesAndDirectories()) + { + var sourceFileClient = directory.GetFileClient(fileItem.Name); + await sourceFileClient.DeleteIfExistsAsync(); + } + + await directory.DeleteAsync(); + } + } + } + } } - } } \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Services/MKIOMediaService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Services/MKIOMediaService.cs new file mode 100644 index 000000000..fb1860afd --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Services/MKIOMediaService.cs @@ -0,0 +1,281 @@ +namespace LearningHub.Nhs.AdminUI.Services +{ + using System; + using System.Globalization; + using System.IdentityModel.Tokens.Jwt; + using System.IO; + using System.Linq; + using System.Net; + using System.Net.Cache; + using System.Text.RegularExpressions; + using System.Threading.Tasks; + using System.Web; + using Azure.Storage.Blobs; + using Azure.Storage.Blobs.Models; + using Azure.Storage.Blobs.Specialized; + using LearningHub.Nhs.AdminUI.Configuration; + using LearningHub.Nhs.AdminUI.Interfaces; + using Microsoft.AspNetCore.Http; + using Microsoft.Azure.Management.Media; + using Microsoft.Azure.Management.Media.Models; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + using Microsoft.IdentityModel.Clients.ActiveDirectory; + using Microsoft.IdentityModel.Tokens; + using Microsoft.Rest; + using Microsoft.Rest.Azure.Authentication; + using MK.IO; + + /// + /// Defines the . + /// + public class MKIOMediaService : IAzureMediaService + { + private readonly ILogger logger; + private WebSettings settings; + private IAzureMediaServicesClient azureMediaServicesClient; + + /// + /// Initializes a new instance of the class. + /// + /// Settings. + /// Logger. + public MKIOMediaService(IOptions settings, ILogger logger) + { + this.settings = settings.Value; + this.logger = logger; + } + + /// + /// Create AzureMedia InputAsset from file upload. + /// + /// File. + /// . + public async Task CreateMediaInputAsset(IFormFile file) + { + string uniqueness = Guid.NewGuid().ToString().Substring(0, 13); + string inputAssetName = $"input-{uniqueness}"; + + var client = this.GetMKIOServicesClientAsync(); + string containerName = "asset-" + Guid.NewGuid().ToString(); + var asset = await client.Assets.CreateOrUpdateAsync(inputAssetName, containerName, this.settings.MediaKindSettings.StorageAccountName); + + BlobServiceClient blobServiceClient = new BlobServiceClient(this.settings.MediaKindSettings.MediaKindStorageConnectionString); + + // Get a reference to the container + BlobContainerClient containerClient = blobServiceClient.GetBlobContainerClient(asset.Properties.Container); + if (!await containerClient.ExistsAsync().ConfigureAwait(false)) + { + await containerClient.CreateIfNotExistsAsync().ConfigureAwait(false); + } + + var filename = Regex.Replace(file.FileName, "[^a-zA-Z0-9.]", string.Empty); + filename = string.IsNullOrEmpty(filename) ? "file.txt" : filename; + + // Get a reference to the blob + BlobClient blobClient = containerClient.GetBlobClient(filename); + + using (var stream = file.OpenReadStream()) + { + await blobClient.UploadAsync(stream).ConfigureAwait(false); + } + + return asset.Name; + } + + /// + /// Download the input media file from Azure Media Services. + /// + /// The name of the input asset. + /// The file name. + /// . + public async Task DownloadMediaInputAsset(string inputAssetName, string fileName) + { + IAzureMediaServicesClient client = await this.CreateMediaServicesClientAsync(); + + AssetContainerSas assetContainerSas = await client.Assets.ListContainerSasAsync( + this.settings.AzureMediaResourceGroup, + this.settings.AzureMediaAccountName, + inputAssetName, + permissions: AssetContainerPermission.Read, + expiryTime: DateTime.UtcNow.AddHours(1).ToUniversalTime()); + + string sasUri = assetContainerSas.AssetContainerSasUrls.First(); + + var blobServiceClient = new BlobContainerClient(new Uri(sasUri)); + var blobClient = blobServiceClient.GetBlockBlobClient(fileName); + var fileContent = await blobClient.DownloadContentAsync(); + + return fileContent; + } + + /// + /// The GetContentAuthenticationTokenAsync. + /// + /// The encodedAssetId. + /// The . + public async Task GetContentAuthenticationTokenAsync(string encodedAssetId) + { + byte[] tokenSigningKey = Convert.FromBase64String(this.settings.MediaKindSettings.JWTPrimaryKeySecret); + + var keyidentifier = string.Empty; // await this.GetContentKeyIdentifier(encodedAssetId); + int expiryMinutes = 0; // this.settings.AzureMediaJWTTokenExpiryMinutes; + + return GetJWTToken(this.settings.MediaKindSettings.Issuer, this.settings.MediaKindSettings.Audience, keyidentifier, tokenSigningKey, expiryMinutes); + } + + /// + /// The GetTopLevelManifestForToken. + /// + /// The manifestProxyUrl. + /// The topLeveLManifestUrl. + /// The token. + /// The . + public string GetTopLevelManifestForToken(string manifestProxyUrl, string topLeveLManifestUrl, string token) + { + ServicePointManager.Expect100Continue = true; + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12; + + this.logger.LogDebug($"topLeveLManifestUrl={topLeveLManifestUrl}"); + var httpRequest = (HttpWebRequest)WebRequest.Create(new Uri(topLeveLManifestUrl)); + httpRequest.CachePolicy = new HttpRequestCachePolicy(HttpRequestCacheLevel.NoCacheNoStore); + httpRequest.Timeout = 30000; + + this.logger.LogDebug($"Calling httpRequest.GetResponse()"); + var httpResponse = httpRequest.GetResponse(); + + try + { + this.logger.LogDebug($"Calling httpResponse.GetResponseStream()"); + var stream = httpResponse.GetResponseStream(); + + if (stream != null) + { + using (var reader = new StreamReader(stream)) + { + const string qualityLevelRegex = @"([^""\s]+\.m3u8\(encryption=cbc\))"; + + var toplevelmanifestcontent = reader.ReadToEnd(); + + var topLevelManifestBaseUrl = topLeveLManifestUrl.Substring(0, topLeveLManifestUrl.IndexOf(".ism", System.StringComparison.OrdinalIgnoreCase)) + ".ism"; + this.logger.LogDebug($"topLevelManifestBaseUrl={topLevelManifestBaseUrl}"); + var urlEncodedTopLeveLManifestBaseUrl = HttpUtility.UrlEncode(topLevelManifestBaseUrl); + var urlEncodedToken = HttpUtility.UrlEncode(token); + + var newContent = Regex.Replace( + toplevelmanifestcontent, + qualityLevelRegex, + string.Format( + CultureInfo.InvariantCulture, + "{0}?playBackUrl={1}/$1&token={2}", + manifestProxyUrl, + urlEncodedTopLeveLManifestBaseUrl, + urlEncodedToken)); + + this.logger.LogDebug($"newContent={newContent}"); + + return newContent; + } + } + } + catch (Exception ex) + { + this.logger.LogDebug($"Exception: {ex.Message}"); + } + finally + { + httpResponse.Close(); + } + + return null; + } + + /// + /// Creates the AzureMediaServicesClient object based on the credentials + /// supplied in local configuration file. + /// + /// . + public async Task CreateMediaServicesClientAsync() + { + if (this.azureMediaServicesClient != null) + { + return this.azureMediaServicesClient; + } + + var credentials = await this.GetCredentialsAsync(); + + this.azureMediaServicesClient = new AzureMediaServicesClient(this.settings.AzureMediaArmEndpoint, credentials) + { + SubscriptionId = this.settings.AzureMediaSubscriptionId, + }; + + return this.azureMediaServicesClient; + } + + /// + /// The GetJWTToken. + /// + /// The issuer. + /// The audience. + /// The keyIdentifier. + /// The tokenVerificationKey. + /// The ExpiryMinutes. + /// The . + private static string GetJWTToken(string issuer, string audience, string keyIdentifier, byte[] tokenVerificationKey, int expiryMinutes) + { + var tokenSigningKey = new SymmetricSecurityKey(tokenVerificationKey); + + SigningCredentials cred = new SigningCredentials( + tokenSigningKey, + SecurityAlgorithms.HmacSha256, // Use the HmacSha256 and not the HmacSha256Signature option, or the token will not work! + SecurityAlgorithms.Sha256Digest); + + // Claim[] claims = new Claim[] + // { + // new Claim(ContentKeyPolicyTokenClaim.ContentKeyIdentifierClaim.ClaimType, keyIdentifier), + // }; + JwtSecurityToken token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + signingCredentials: cred); + + // claims: claims, + // notBefore: DateTime.Now.AddMinutes(-5), + // expires: DateTime.Now.AddMinutes(expiryMinutes), + JwtSecurityTokenHandler handler = new JwtSecurityTokenHandler(); + + return handler.WriteToken(token); + } + + /// + /// Get AzureMedia Credentials. + /// + /// . + private async Task GetCredentialsAsync() + { + ClientCredential clientCredential = new ClientCredential(this.settings.AzureMediaAadClientId, this.settings.AzureMediaAadSecret); + return await ApplicationTokenProvider.LoginSilentAsync(this.settings.AzureMediaAadTenantId, clientCredential, ActiveDirectoryServiceSettings.Azure); + } + + /// + /// The GetContentKeyIdentifier. + /// + /// The encodedAssetId. + /// The . + private async Task GetContentKeyIdentifier(string encodedAssetId) + { + var client = await this.CreateMediaServicesClientAsync(); + + var streamingLocatorsResponse = await client.Assets.ListStreamingLocatorsAsync(this.settings.AzureMediaResourceGroup, this.settings.AzureMediaAccountName, encodedAssetId); + var contentKeyResponse = await client.StreamingLocators.ListContentKeysAsync(this.settings.AzureMediaResourceGroup, this.settings.AzureMediaAccountName, streamingLocatorsResponse.StreamingLocators.First().Name); + string keyIdentifier = contentKeyResponse.ContentKeys.First().Id.ToString(); + + return keyIdentifier; + } + + private MKIOClient GetMKIOServicesClientAsync() + { + return new MKIOClient(this.settings.MediaKindSettings.SubscriptionName, this.settings.MediaKindSettings.Token); + } + } +} diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Services/ResourceService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Services/ResourceService.cs index d0d595b52..4c6e3fe46 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Services/ResourceService.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Services/ResourceService.cs @@ -2,6 +2,7 @@ { using System; using System.Collections.Generic; + using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; @@ -130,6 +131,89 @@ public async Task GetResourceVersionVa return viewmodel; } + /// + /// The GetResourceVersionDevIdDetailsAsync. + /// + /// The resourceVersionId. + /// The . + public async Task GetResourceVersionDevIdDetailsAsync(int resourceVersionId) + { + ResourceVersionDevIdViewModel viewmodel = null; + + var client = await this.LearningHubHttpClient.GetClientAsync(); + + var request = $"Resource/GetResourceVersionDevIdDetails/{resourceVersionId.ToString()}"; + var response = await client.GetAsync(request).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + var result = response.Content.ReadAsStringAsync().Result; + viewmodel = JsonConvert.DeserializeObject(result); + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized + || + response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + + return viewmodel; + } + + /// + /// The GetResourceVersionDevIdDetailsAsync. + /// + /// The devId. + /// The . + public async Task DoesDevIdExistsAsync(string devId) + { + var client = await this.LearningHubHttpClient.GetClientAsync(); + + var request = $"Resource/DoesDevIdExists/{devId}"; + var response = await client.GetAsync(request).ConfigureAwait(false); + var doesDevIdExist = false; + if (response.IsSuccessStatusCode) + { + var result = response.Content.ReadAsStringAsync().Result; + doesDevIdExist = JsonConvert.DeserializeObject(result); + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized + || + response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + + return doesDevIdExist; + } + + /// + /// Update dev id details for a resource. + /// + /// The model. + /// The . + /// the exception. + public async Task UpdateDevIdDetailsAsync(ResourceVersionDevIdViewModel model) + { + var json = JsonConvert.SerializeObject(model); + var stringContent = new StringContent(json, UnicodeEncoding.UTF8, "application/json"); + + var client = await this.LearningHubHttpClient.GetClientAsync(); + + var request = $"Resource/UpdateDevId"; + var response = await client.PutAsync(request, stringContent).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + + if (!response.IsSuccessStatusCode) + { + throw new Exception("Update first name failed!"); + } + } + /// /// The GetResourceVersionExtendedViewModelAsync. /// diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Styles/Layout/_footer.scss b/AdminUI/LearningHub.Nhs.AdminUI/Styles/Layout/_footer.scss index 3423503ae..c372b4b21 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Styles/Layout/_footer.scss +++ b/AdminUI/LearningHub.Nhs.AdminUI/Styles/Layout/_footer.scss @@ -28,9 +28,13 @@ .footer-primary, .footer-secondary { - font-size: 17px; - line-height: 28px; - padding-top: 30px; + font-size: 17px; + line-height: 28px; + padding-top: 30px; + + @media (max-width: 420px) { + padding-top: 20px !important; + } } .footer-primary { @@ -63,7 +67,7 @@ padding-right: 15px; @media (max-width: 420px) { - padding-top: 10px !important; + padding-top: 0px !important; text-align: left !important; } } diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Styles/Pages/page_detail.scss b/AdminUI/LearningHub.Nhs.AdminUI/Styles/Pages/page_detail.scss index b037259dd..139bd025d 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Styles/Pages/page_detail.scss +++ b/AdminUI/LearningHub.Nhs.AdminUI/Styles/Pages/page_detail.scss @@ -252,6 +252,10 @@ h4 { font-size: 16px; } +.information-page__row h1,h2,h3,h4 { + font-family: "Frutiger W01", Arial, sans-serif !important; +} + /* desktop */ @media (min-width: 990px) { } diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Views/Catalogue/Edit.cshtml b/AdminUI/LearningHub.Nhs.AdminUI/Views/Catalogue/Edit.cshtml index 788bfaba3..19ed8d399 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Views/Catalogue/Edit.cshtml +++ b/AdminUI/LearningHub.Nhs.AdminUI/Views/Catalogue/Edit.cshtml @@ -3,430 +3,458 @@ @using LearningHub.Nhs.Models.Enums @using Newtonsoft.Json @{ - var page = Model.CatalogueNodeVersionId == 0 ? CatalogueNavPage.Add : CatalogueNavPage.Edit; - ViewData["Title"] = page == CatalogueNavPage.Add ? "Add catalogue" : "Edit catalogue"; - var baseUrl = _settings.Value.LearningHubUrl + "catalogue/"; - var url = baseUrl + (Model.Url ?? ""); - var aToZ = (int)CatalogueOrder.AlphabeticalAscending; - var keywords = Model.Keywords ?? new List(); - var Providers = Model.Providers; - var CatalogueNodeVersionProviderId = Model.CatalogueNodeVersionProvider?.ProviderId ?? 0; - var CatalogueNodeVersionProvider = Model.CatalogueNodeVersionProvider; - var action = page.ToString(); - var keywordsJson = Html.Raw(JsonConvert.SerializeObject(keywords)); - var imageBaseUrl = "/file/download/CatalogueImageDirectory/"; - var lastModifiedDate = Model.LastModifiedDate?.ToString("dd MMM yyyy"); + var page = Model.CatalogueNodeVersionId == 0 ? CatalogueNavPage.Add : CatalogueNavPage.Edit; + ViewData["Title"] = page == CatalogueNavPage.Add ? "Add catalogue" : "Edit catalogue"; + var baseUrl = _settings.Value.LearningHubUrl + "catalogue/"; + var url = baseUrl + (Model.Url ?? ""); + var aToZ = (int)CatalogueOrder.AlphabeticalAscending; + var keywords = Model.Keywords ?? new List(); + var Providers = Model.Providers; + var CatalogueNodeVersionProviderId = Model.CatalogueNodeVersionProvider?.ProviderId ?? 0; + var CatalogueNodeVersionProvider = Model.CatalogueNodeVersionProvider; + var action = page.ToString(); + var keywordsJson = Html.Raw(JsonConvert.SerializeObject(keywords)); + var imageBaseUrl = "/file/download/CatalogueImageDirectory/"; + var lastModifiedDate = Model.LastModifiedDate?.ToString("dd MMM yyyy"); } @section SideMenu { - @{ - await Html.RenderPartialAsync("_NavSection"); - } + @{ + await Html.RenderPartialAsync("_NavSection"); + } } @if (!string.IsNullOrEmpty(ViewBag.ErrorMessage)) { - + } @if (page == CatalogueNavPage.Edit) { -
-
-

@Model.Name

-
+
+
+

@Model.Name

+
} @{ - await Html.RenderPartialAsync("_CatalogueNav.cshtml", new CatalogueNavViewModel { Page = page, CatalogueId = Model.CatalogueNodeVersionId }); + await Html.RenderPartialAsync("_CatalogueNav.cshtml", new CatalogueNavViewModel { Page = page, CatalogueId = Model.CatalogueNodeVersionId }); }
-
- - - - -
-
-
- @if (page == CatalogueNavPage.Edit) - { - var idString = Model.CatalogueNodeVersionId.ToString(); - var paddedIdString = idString.Length > 3 ? idString : idString.PadLeft(3, '0'); - ID: @paddedIdString - } - else - { - NEW - } -
- @if (page == CatalogueNavPage.Add || Model.Hidden) - { - Hidden - } - @if (lastModifiedDate != null) - { -

Last modified on: @lastModifiedDate

- } -
+ + + + + +
+
+
+ @if (page == CatalogueNavPage.Edit) + { + var idString = Model.CatalogueNodeVersionId.ToString(); + var paddedIdString = idString.Length > 3 ? idString : idString.PadLeft(3, '0'); + ID: @paddedIdString + } + else + { + NEW + }
- - @if (!ViewData.ModelState.IsValid) + @if (page == CatalogueNavPage.Add || Model.Hidden) + { + Hidden + } + @if (lastModifiedDate != null) { -
This form failed to save because there are errors below.
+

Last modified on: @lastModifiedDate

} +
+
-
-
- -

You can enter a maximum of 255 characters including spaces

- - -
-
-
-
- - @if (page == CatalogueNavPage.Add) - { - - } - else - { - - } - -
-
- @if (Model.Status == VersionStatusEnum.Published && !Model.Hidden) - { - - } - else - { -

- } -
-
-
-
- -
-
-
-
- - -
-
-
-
- + @if (!ViewData.ModelState.IsValid) + { +
This form failed to save because there are errors below.
+ } + +
+
+ +

You can enter a maximum of 255 characters including spaces

+ + +
+
+
+
+
+ +
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ + + Only the first 3,000 characters of the description will be used by search + +
+
+
+
+ +

+ Add one or more relevant keywords to help learners find this catalogue, ensuring that the keyword is specifically related to the catalogue rather than the resources associated with it. A maximum of 5 keywords can be added. Enter one keyword and add it before entering another. +

+
+
+ +
+
+
-
-
- -
+
+
+ + +
-
-
- - - You have 1800 characters remaining - -
+
+
+ +
-
-
- -

- Add one or more relevant keywords to help learners find this catalogue, ensuring that the keyword is specifically related to the catalogue rather than the resources associated with it. A maximum of 5 keywords can be added. Enter one keyword and add it before entering another. -

-
-
- -
- -
-
-
-
-
- - -
-
+
+
+

You can enter a maximum of 50 characters per keyword.

+
+ @{ + var i = 0; + } + @foreach (var keyword in keywords) + { +
+

@keyword

+ +
-
-

You can enter a maximum of 50 characters

-
- @{ - var i = 0; - } - @foreach (var keyword in keywords) - { -
-

@keyword

- - -
- } -
-
+ }
+
+
-
+
-
-
- -
-
+
+
+ +
+
-
-
-
- @if (!Model.RestrictedAccess) - { - - } - else - { - - } - Unrestricted access (default) -
-
- @if (Model.RestrictedAccess) - { - - } - else - { - - } - Restricted access -
-
-
- @if (!Model.HasUserGroup) +
+
+
+ @if (!Model.RestrictedAccess) + { + + } + else { -
-

There are no user groups associated with this catalogue. You can add some in the User Groups tab.

-
+ } + Unrestricted access (default) +
+
+ @if (Model.RestrictedAccess) + { + + } + else + { + + } + Restricted access +
+
+ @if (!Model.HasUserGroup) + { +
+

There are no user groups associated with this catalogue. You can add some in the User Groups tab.

+
+ } +
-
- -
-
- -

When applicable please select the provider of this content. This will allow a contributor to flag content from a specific provider.

-

This will enable learners to search for content produced by organisations and help separate learning resources from community contributions.

-

Provided by;

-
-
+
-
-
- @if (Providers != null && Providers.Count() > 0) - { -
- @foreach (var provider in Providers) - { - - @provider.Name -
- } - - Not applicable -
- } -
-
+
+
+ +

When applicable please select the provider of this content. This will allow a contributor to flag content from a specific provider.

+

This will enable learners to search for content produced by organisations and help separate learning resources from community contributions.

+

Developed with;

-
-
- @if (page == CatalogueNavPage.Add) - { - - } - else - { - - } +
+ +
+
+ @if (Providers != null && Providers.Count() > 0) + { +
+ @foreach (var provider in Providers) + { + + @provider.Name +
+ } + + Not applicable
+ }
- -
+
+
+
+
+ @if (page == CatalogueNavPage.Add) + { + + } + else + { + + } +
+
+ +
@section Scripts { - - -} \ No newline at end of file + CKEDITOR.on('instanceReady', function () { + var editor = CKEDITOR.instances['Description']; + + editor.on('change', function () { + var editorData = editor.getData(); + var data = $("

").html(editorData).text(); + var textData = data.replace(/\s\n\n/g, ' ').replace(/\n\n/g, ' ').replace(/\s\n/g, '').replace(/\n/g, ''); + }); + + editor.fire('change'); + }); + + }); + + + +} diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Views/Cms/PageDetail.cshtml b/AdminUI/LearningHub.Nhs.AdminUI/Views/Cms/PageDetail.cshtml index f6ae3a2da..ca848cc75 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Views/Cms/PageDetail.cshtml +++ b/AdminUI/LearningHub.Nhs.AdminUI/Views/Cms/PageDetail.cshtml @@ -3,7 +3,8 @@ } @section Styles{ - + @* *@ + } @@ -14,5 +15,5 @@ @section Scripts{ - + @* *@ } \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Views/Resource/Details.cshtml b/AdminUI/LearningHub.Nhs.AdminUI/Views/Resource/Details.cshtml index 83f90382b..44b2f9b11 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Views/Resource/Details.cshtml +++ b/AdminUI/LearningHub.Nhs.AdminUI/Views/Resource/Details.cshtml @@ -5,862 +5,913 @@ @inject IOptions webSettings @{ - ViewData["Title"] = "Details"; + ViewData["Title"] = "Details"; + var activetab = this.ViewBag.ActiveTab; } @section Styles{ - + }
- -
-
+ +
+
+
+ + @if (Model.ResourceVersionId == 0) + { +
Resource Version not found
+ } + else + { +
+
@Model.Title (@Model.VersionStatusDescription)
+
+ @if (Model.VersionStatusEnum == VersionStatusEnum.Published) + { + + } + @if (Model.VersionStatusEnum == VersionStatusEnum.Publishing || Model.VersionStatusEnum == VersionStatusEnum.FailedToPublish) + { + + } + +
- - @if (Model.ResourceVersionId == 0) - { -
Resource Version not found
- } - else - { -
-
@Model.Title (@Model.VersionStatusDescription)
-
- @if (Model.VersionStatusEnum == VersionStatusEnum.Published) - { - - } - @if (Model.VersionStatusEnum == VersionStatusEnum.Publishing || Model.VersionStatusEnum == VersionStatusEnum.FailedToPublish) - { - - } - -
-
-