diff --git a/cds/config.py b/cds/config.py index 66b337d84..31e83afab 100644 --- a/cds/config.py +++ b/cds/config.py @@ -1127,7 +1127,7 @@ def _parse_env_bool(var_name, default=None): "'unsafe-inline'", ], "img-src": ["'self'", "https://*.theoplayer.com", "data:"], - "connect-src": ["'self'", "https://*.theoplayer.com", "https://*.cern.ch"], + "connect-src": ["'self'", "https://*.theoplayer.com", "https://*.cern.ch", "blob:"], "object-src": ["'self'"], "media-src": ["'self'", "blob:"], "frame-src": ["'self'", "https://*.theoplayer.com"], diff --git a/cds/modules/previewer/extensions/video.py b/cds/modules/previewer/extensions/video.py index 52ef2c31f..3f01305fb 100644 --- a/cds/modules/previewer/extensions/video.py +++ b/cds/modules/previewer/extensions/video.py @@ -25,6 +25,7 @@ """Previews video files.""" +from cds.modules.records.utils import parse_video_chapters from flask import render_template @@ -63,6 +64,12 @@ def preview(self, file, embed_config=None): if "report_number" in record and len(record["report_number"]) else "" ) + + description = record.get('description', '') + if description: + record['chapters'] = parse_video_chapters(description) + else: + record['chapters'] = [] return render_template( self.template, diff --git a/cds/modules/previewer/templates/cds_previewer/macros/player.html b/cds/modules/previewer/templates/cds_previewer/macros/player.html index b5b3a3902..8144c430f 100644 --- a/cds/modules/previewer/templates/cds_previewer/macros/player.html +++ b/cds/modules/previewer/templates/cds_previewer/macros/player.html @@ -66,77 +66,27 @@ } return null; } - function cleanHtmlFromTitle(title) { - if (!title) return title; - - // Decode HTML entities by using a temporary DOM element - const temp = document.createElement("textarea"); - temp.innerHTML = title; - let decoded = temp.value; - decoded = decoded.replace(/<[^>]+>/g, " "); - decoded = decoded.replace(/\s+/g, " ").trim(); - - return decoded; - } - function parseChapters(description) { - if (!description) return []; - - const pattern = /(?:^|\n)\s*(\d{1,2}:(?:\d{1,2}:)?\d{1,2})\s*[-\s]*(.+?)(?=\n|$)/gm; - const chapters = []; - let match; - - while ((match = pattern.exec(description)) !== null) { - const [, timestampStr, title] = match; - - const timeParts = timestampStr.split(":"); - let totalSeconds; - - if (timeParts.length === 2) { - const [minutes, seconds] = timeParts.map(Number); - totalSeconds = minutes * 60 + seconds; - } else if (timeParts.length === 3) { - const [hours, minutes, seconds] = timeParts.map(Number); - totalSeconds = hours * 3600 + minutes * 60 + seconds; - } else { - continue; - } - - const cleanTitle = cleanHtmlFromTitle(title); - if (cleanTitle) { - chapters.push({ - startTime: totalSeconds, - text: cleanTitle - }); - } - } - - chapters.sort((a, b) => a.startTime - b.startTime); - - // Add endTime for VTT - const videoDuration = durationToSeconds({{ (record.duration if record and record.duration else "") | tojson }}); - for (let i = 0; i < chapters.length; i++) { - if (i + 1 < chapters.length) { - chapters[i].endTime = chapters[i + 1].startTime; - } else { - chapters[i].endTime = videoDuration || (chapters[i].startTime + 1); - } - } - - return chapters; - } function formatVttTime(total) { const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = Math.floor(total % 60); return String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0') + '.000'; } - function buildChaptersVtt(description) { - const chapters = parseChapters(description); - if (!chapters.length) return null; + function buildChaptersVttFromArray(chaptersArr, videoDurationStr) { + if (!chaptersArr || !chaptersArr.length) return null; + + chaptersArr.sort((a,b) => a.seconds - b.seconds); + const videoDuration = durationToSeconds(videoDurationStr); + if (videoDuration !== null && videoDuration <= 0) return null; let vtt = 'WEBVTT\n\n'; - for (let i = 0; i < chapters.length; i++) { - const c = chapters[i]; - vtt += `${i+1}\n${formatVttTime(c.startTime)} --> ${formatVttTime(c.endTime)}\n${c.text}\n\n`; + for (let i = 0; i < chaptersArr.length; i++) { + const c = chaptersArr[i]; + const start = c.seconds; + const end = (i + 1 < chaptersArr.length) + ? chaptersArr[i+1].seconds + : (videoDuration || start + 1); + + vtt += `${i+1}\n${formatVttTime(start)} --> ${formatVttTime(end)}\n${c.title}\n\n`; } return vtt; } @@ -144,8 +94,9 @@ // Build textTracks var textTracksArr = []; // Chapters - var descStr = {{ (record.description if record and record.description else "") | tojson }}; - var vttStr = buildChaptersVtt(descStr); + var chaptersArr = {{ (record.chapters if record and record.chapters else []) | tojson }}; + var videoDurationStr = {{ (record.duration if record and record.duration else "") | tojson }}; + var vttStr = buildChaptersVttFromArray(chaptersArr, videoDurationStr); if (vttStr) { var chapUrl = URL.createObjectURL(new Blob([vttStr], { type: 'text/vtt' })); textTracksArr.push({ kind: 'chapters', src: chapUrl, label: 'Chapters' }); diff --git a/cds/modules/records/serializers/json.py b/cds/modules/records/serializers/json.py index 9524d15f9..0a05d85de 100644 --- a/cds/modules/records/serializers/json.py +++ b/cds/modules/records/serializers/json.py @@ -31,7 +31,7 @@ has_read_record_eos_path_permission, has_read_record_permission, ) -from ..utils import HTMLTagRemover, remove_html_tags +from ..utils import HTMLTagRemover, parse_video_chapters, remove_html_tags class CDSJSONSerializer(JSONSerializer): @@ -81,6 +81,12 @@ def preprocess_record(self, pid, record, links_factory=None): except KeyError: # ignore error if keys are missing in the metadata pass + + description = metadata.get('description', '') + if description: + metadata['chapters'] = parse_video_chapters(description) + else: + metadata['chapters'] = [] return result diff --git a/cds/modules/records/serializers/schemas/video.py b/cds/modules/records/serializers/schemas/video.py index 13b34ac89..66e9f763f 100644 --- a/cds/modules/records/serializers/schemas/video.py +++ b/cds/modules/records/serializers/schemas/video.py @@ -167,7 +167,6 @@ class VideoSchema(StrictKeysSchema): ) collections = fields.List(fields.Str, many=True) additional_languages = fields.List(fields.Str, many=True) - chapters = fields.List(fields.Dict, dump_only=True) # Preservation fields location = fields.Str() @@ -180,13 +179,3 @@ def post_load(self, data, **kwargs): data["$schema"] = current_jsonschemas.path_to_url(Video._schema) return data - @post_dump(pass_many=False) - def post_dump(self, data, **kwargs): - """Post dump - add parsed chapters.""" - description = data.get('description', '') - if description: - data['chapters'] = parse_video_chapters(description) - else: - data['chapters'] = [] - - return data diff --git a/cds/modules/records/static/templates/cds_records/video/detail.html b/cds/modules/records/static/templates/cds_records/video/detail.html index f28126fec..76b7ddf9d 100644 --- a/cds/modules/records/static/templates/cds_records/video/detail.html +++ b/cds/modules/records/static/templates/cds_records/video/detail.html @@ -4,7 +4,7 @@
-
+
@@ -21,7 +21,7 @@
+ id="inThisVideoSection">

@@ -316,13 +316,12 @@

Chapters

- +
diff --git a/cds/modules/records/utils.py b/cds/modules/records/utils.py index 926baf3df..f269dc5e5 100644 --- a/cds/modules/records/utils.py +++ b/cds/modules/records/utils.py @@ -500,6 +500,7 @@ def parse_video_chapters(description): Returns: list: List of chapter dicts with 'timestamp', 'seconds', and 'title' keys """ + html_tag_remover = HTMLTagRemover() if not description: return [] @@ -524,7 +525,7 @@ def parse_video_chapters(description): continue # Clean up title - title = title.strip() + title = remove_html_tags(html_tag_remover, title).strip() if title: chapters.append({ 'timestamp': timestamp_str, diff --git a/cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js b/cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js index ae2559b58..fce62a650 100644 --- a/cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js +++ b/cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js @@ -67,6 +67,26 @@ function cdsRecordController($scope, $sce, $http, $timeout, $filter) { "X-CSRFToken": getCookie("csrftoken"), }; + $scope.scrollToElement = function (id) { + setTimeout(function () { + const el = document.getElementById(id); + if (el) { + const rect = el.getBoundingClientRect(); + const isVisible = + rect.top >= 0 && + rect.bottom <= + (window.innerHeight || document.documentElement.clientHeight); + + if (!isVisible) { + const topOffset = rect.top + window.scrollY - 60; // adjust for sticky header + window.scrollTo({ top: topOffset, behavior: "smooth" }); + } + } else { + console.warn("Element not found:", id); + } + }, 100); + }; + $scope.seekTo = function (timecode) { const player = window.top.player; if (player) { @@ -92,27 +112,18 @@ function cdsRecordController($scope, $sce, $http, $timeout, $filter) { } }; + $scope.jumpToChapter = function (timecode) { + $scope.scrollToElement("videoPlayerSection"); + $scope.seekTo(timecode); + }; + $scope.toggleInThisVideo = function (tab) { $scope.showInThisVideoSection = !$scope.showInThisVideoSection; $scope.activeTab = tab; // Jump to Transcriptions section if ($scope.showInThisVideoSection) { - setTimeout(function () { - const el = document.getElementById("transcriptionsSection"); - if (el) { - const rect = el.getBoundingClientRect(); - const isVisible = - rect.top >= 0 && - rect.bottom <= - (window.innerHeight || document.documentElement.clientHeight); - - if (!isVisible) { - const topOffset = rect.top + window.scrollY - 60; - window.scrollTo({ top: topOffset, behavior: "smooth" }); - } - } - }, 100); + $scope.scrollToElement("inThisVideoSection"); } }; @@ -197,8 +208,6 @@ function cdsRecordController($scope, $sce, $http, $timeout, $filter) { // Use chapters from API or parse from description as fallback if (record.metadata.chapters && record.metadata.chapters.length > 0) { $scope.chapters = record.metadata.chapters; - } else if (record.metadata.description) { - $scope.chapters = $scope.parseChapters(record.metadata.description); } // Set default active tab based on what's available (prioritize chapters) @@ -345,50 +354,6 @@ function cdsRecordController($scope, $sce, $http, $timeout, $filter) { return `${minutes}:${paddedSecs}`; }; - $scope.parseChapters = function (description) { - if (!description) return []; - - // Regex pattern to match timestamp formats: 0:00, 00:00, 0:00:00, 00:00:00 - const pattern = - /(?:^|\n)\s*(\d{1,2}:(?:\d{1,2}:)?\d{1,2})\s*[-\s]*(.+?)(?=\n|$)/gm; - const chapters = []; - let match; - - while ((match = pattern.exec(description)) !== null) { - const [, timestampStr, title] = match; - - // Parse timestamp to seconds - const timeParts = timestampStr.split(":"); - let totalSeconds; - - if (timeParts.length === 2) { - // MM:SS format - const [minutes, seconds] = timeParts.map(Number); - totalSeconds = minutes * 60 + seconds; - } else if (timeParts.length === 3) { - // HH:MM:SS format - const [hours, minutes, seconds] = timeParts.map(Number); - totalSeconds = hours * 3600 + minutes * 60 + seconds; - } else { - continue; - } - - // Clean up title - const cleanTitle = title.trim(); - if (cleanTitle) { - chapters.push({ - timestamp: timestampStr, - seconds: totalSeconds, - title: cleanTitle, - }); - } - } - - // Sort chapters by timestamp - chapters.sort((a, b) => a.seconds - b.seconds); - return chapters; - }; - $scope.setActiveTab = function (tab) { $scope.activeTab = tab; }; @@ -415,7 +380,7 @@ function cdsRecordController($scope, $sce, $http, $timeout, $filter) { } // Return clickable timestamp using onclick for ng-bind-html compatibility - return `${match}`; + return `${match}`; }); };