Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cds/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
7 changes: 7 additions & 0 deletions cds/modules/previewer/extensions/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"""Previews video files."""


from cds.modules.records.utils import parse_video_chapters
from flask import render_template


Expand Down Expand Up @@ -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,
Expand Down
83 changes: 17 additions & 66 deletions cds/modules/previewer/templates/cds_previewer/macros/player.html
Original file line number Diff line number Diff line change
Expand Up @@ -66,86 +66,37 @@
}
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;
}

// 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' });
Expand Down
8 changes: 7 additions & 1 deletion cds/modules/records/serializers/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down
11 changes: 0 additions & 11 deletions cds/modules/records/serializers/schemas/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<div class="cds-detail-video cds-detail-video-wrapper bg-n pb-20">
<!-- Video Section -->
<div ng-if="record.metadata.recid && (record | findMaster).key" class="bg-b">
<div ng-class="{ 'container-fluid': showInThisVideoSection }">
<div ng-class="{ 'container-fluid': showInThisVideoSection }" class="video-section" id="videoPlayerSection">
<div ng-class="{ 'row': ((record.metadata._files | filter:{context_type:'subtitle', content_type:'vtt'}).length > 0 || chapters.length > 0) && showInThisVideoSection }">
<div ng-class="{ 'col-md-8 pt-20': ((record.metadata._files | filter:{context_type:'subtitle', content_type:'vtt'}).length > 0 || chapters.length > 0) && showInThisVideoSection }">
<div class="cds-video-iframe text-center">
Expand All @@ -21,7 +21,7 @@
<!-- Section In this video -->
<div ng-show="((record.metadata._files | filter:{context_type:'subtitle', content_type:'vtt'}).length > 0 || chapters.length > 0) && showInThisVideoSection"
class="col-md-4 bg-b pt-20"
id="transcriptionsSection">
id="inThisVideoSection">
<div class="in-this-video-panel bg-w pl-10 pb-10 pr-10 pt-10">
<div class="cds-detail-title cds-detail-video-title">
<h3 class="bt bw-1 pt-10 mb-0">
Expand Down Expand Up @@ -316,13 +316,12 @@ <h3><strong>Chapters</strong></h3>
</button>
</div>

<!-- Horizontal Chapter List (first 6 always visible) -->
<!-- Horizontal Chapter List -->
<div class="chapters-main-horizontal" style="overflow-x: auto; overflow-y: hidden; white-space: nowrap;">
<div style="display: inline-flex; gap: 15px; padding-bottom: 8px; padding-top: 8px;">
<div ng-repeat="chapter in chapters"
ng-if="$index < 6"
class="chapter-item-horizontal-main"
ng-click="seekTo(chapter.seconds)"
ng-click="jumpToChapter(chapter.seconds)"
style="display: inline-block; cursor: pointer; width: 180px; flex-shrink: 0; padding: 12px; border: 1px solid #e1e8ed; border-radius: 8px; transition: all 0.2s ease; background-color: white;"
onmouseover="this.style.backgroundColor='#f8f9fa'; this.style.borderColor='#2196F3'; this.style.transform='translateY(-2px)';"
onmouseout="this.style.backgroundColor='white'; this.style.borderColor='#e1e8ed'; this.style.transform='translateY(0)';">
Expand Down
3 changes: 2 additions & 1 deletion cds/modules/records/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []

Expand All @@ -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,
Expand Down
89 changes: 27 additions & 62 deletions cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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");
}
};

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
};
Expand All @@ -415,7 +380,7 @@ function cdsRecordController($scope, $sce, $http, $timeout, $filter) {
}

// Return clickable timestamp using onclick for ng-bind-html compatibility
return `<a href="javascript:void(0)" class="timestamp-link" onclick="(function(){ try { angular.element(document.querySelector('.cds-detail-description')).scope().seekTo(${totalSeconds}); } catch(e) { console.error('Could not seek to timestamp:', e); } })()" style="color: #2196F3; font-weight: 600; cursor: pointer;">${match}</a>`;
return `<a href="javascript:void(0)" class="timestamp-link" onclick="(function(){ try { angular.element(document.querySelector('.cds-detail-description')).scope().jumpToChapter(${totalSeconds}); } catch(e) { console.error('Could not seek to timestamp:', e); } })()" style="color: #2196F3; font-weight: 600; cursor: pointer;">${match}</a>`;
});
};

Expand Down