diff --git a/filter.php b/filter.php index e5c3c44b9ee8b420f7cbf21238afe52ed7f9948e..713b1f954c76661b3534ae77bc1e069129bb3cb7 100644 --- a/filter.php +++ b/filter.php @@ -20,39 +20,68 @@ * This filter will replace any links to opencast videos with the selected player from opencast. * * @package filter_opencast - * @copyright 2018 Tamara Gunkel + * @copyright 2024 Justus Dieckmann and Tamara Gunkel, University of Münster * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - use mod_opencast\local\paella_transform; /** * Automatic opencast videos filter class. * * @package filter_opencast - * @copyright 2018 Tamara Gunkel + * @copyright 2024 Justus Dieckmann and Tamara Gunkel, University of Münster * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class filter_opencast extends moodle_text_filter { + /** + * Get the content of the attribute $attributename from $tag. + * @param string $tag HTML-Tag. + * @param string $attributename Name of the attribute. + * @param string $attributecontentregex Regex of what the content of the attribute might be. + * @return string|null The content of the attribute or null, if it doesn't exist. + */ + private static function get_attribute(string $tag, string $attributename, string $attributecontentregex = '.*'): string|null { + $pattern = "/$attributename=(['\"])($attributecontentregex)\\1/"; + preg_match($pattern, $tag, $matches); + return $matches[2] ?? null; + } + + /** + * Test whether $url matches one of the episodeurls. + * @param string $url The url to test. + * @param array $episodeurls array of [ocinstanceid, episoderegex, baseurl]. + * @return array|null [ocinstanceid, episodeid] or null if there are no matches. + */ + private static function test_url(string $url, array $episodeurls) : array|null { + foreach ($episodeurls as [$ocinstanceid, $episoderegex, $baseurl]) { + if (preg_match_all($episoderegex, $url, $matches)) { + return [$ocinstanceid, $matches[1][0]]; + } + } + return null; + } + /** * Replaces Opencast videos embedded in <video> tags by the paella player. + * * @param string $text * @param array $options * @return array|mixed|string|string[]|null - * @throws dml_exception */ public function filter($text, array $options = []) { - global $PAGE, $OUTPUT; - $i = 0; - if (stripos($text, '</video>') === false) { - // Performance shortcut - if there is no </video> tag, nothing can match. + if (preg_match('</(a|video)>', $text) !== 1) { + // Performance shortcut - if there are no </video> or </a> tags, nothing can match. return $text; } - foreach (\tool_opencast\local\settings_api::get_ocinstances() as $ocinstance) { + // First section: (Relatively) quick check if there are episode urls in the text, and only look for these later. + // Improvable by combining all episode urls into one big regex if needed. + $ocinstances = \tool_opencast\local\settings_api::get_ocinstances(); + $occurrences = []; + foreach ($ocinstances as $ocinstance) { $episodeurls = get_config('filter_opencast', 'episodeurl_' . $ocinstance->id); if (!$episodeurls) { @@ -63,120 +92,174 @@ class filter_opencast extends moodle_text_filter { $episodeurl = trim($episodeurl); $urlparts = parse_url($episodeurl); + if (!isset($urlparts['scheme']) || !isset($urlparts['host'])) { + continue; + } $baseurl = $urlparts['scheme'] . '://' . $urlparts['host']; if (isset($urlparts['port'])) { $baseurl .= ':' . $urlparts['port']; } - if (empty($episodeurl) || stripos($text, $baseurl) === false) { - continue; + if (self::str_contains($text, $baseurl)) { + $episoderegex = "/" . preg_quote($episodeurl, "/") . "/"; + $episoderegex = preg_replace('/\\\\\[EPISODEID\\\]/', '([0-9a-zA-Z\-]+)', $episoderegex); + $occurrences[] = [$ocinstance->id, $episoderegex, $baseurl]; } + } + } - // Looking for tags. - $matches = preg_split('/(<[^>]*>)/i', $text, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE); - if ($matches) { - $renderer = $PAGE->get_renderer('filter_opencast'); - $video = false; - $width = false; - $height = false; - - foreach ($matches as $match) { - if (empty(trim($match))) { - continue; - } - // Check if the match is a video tag. - if (substr($match, 0, 6) === "<video") { - $video = true; - preg_match('/width="([0-9]+)"/', $match, $width); - preg_match('/height="([0-9]+)"/', $match, $height); - $width = $width ? $width[1] : $width; - $height = $height ? $height[1] : $height; - } else if ($video) { - $video = false; - if (substr($match, 0, 7) === "<source") { - - // Check if video is from opencast. - if (strpos($match, $baseurl) === false) { - $width = $height = false; - continue; - } - - // Extract url. - preg_match_all('/<source[^>]+src=([\'"])(?<src>.+?)\1[^>]*>/i', $match, $result); - - // Change url for loading the (Paella) Player. - $link = $result['src'][0]; - - // Get episode id from link. - $episoderegex = "/" . preg_quote($episodeurl, "/") . "/"; - $episoderegex = preg_replace('/\\\\\[EPISODEID\\\]/', '([0-9a-zA-Z\-]+)', $episoderegex); - $nummatches = preg_match_all($episoderegex, $link, $episodeid); - - if (!$nummatches) { - $width = $height = false; - continue; - } - - $data = paella_transform::get_paella_data_json($ocinstance->id, $episodeid[1][0]); - - if (!$data) { - continue; - } - - // Collect the needed data being submitted to the template. - $mustachedata = new stdClass(); - $mustachedata->playerid = 'ocplayer_' . $i++; - $mustachedata->configurl = (new moodle_url(get_config('filter_opencast', 'configurl_' . $ocinstance->id)))->out(false); - $mustachedata->themeurl = (new moodle_url(get_config('mod_opencast', 'themeurl_' . $ocinstance->id)))->out(false); - if (strpos($mustachedata->configurl, 'http') === false) { - $mustachedata->configurl = (new moodle_url($mustachedata->configurl))->out(); - } - - $mustachedata->data = json_encode($data); - $mustachedata->width = $width; - $mustachedata->height = $height; - $mustachedata->modplayerpath = (new moodle_url('/mod/opencast/player.html'))->out(); - - if (isset($data['streams'])) { - if (count($data['streams']) === 1) { - $sources = $data['streams'][0]['sources']; - $res = $sources[array_key_first($sources)][0]['res']; - $resolution = $res['w'] . '/' . $res['h']; - $mustachedata->resolution = $resolution; - - if ($width xor $height) { - if ($width) { - $mustachedata->height = $width * ($res['h'] / $res['w']); - } else if ($height) { - $mustachedata->width = $height * ($res['w'] / $res['h']); - } - } - } else { - if ($width && $height) { - $mustachedata->width = $width; - $mustachedata->height = $height; - } - } - $newtext = $renderer->render_player($mustachedata); - } else { - $newtext = $OUTPUT->render(new \core\output\notification( - get_string('erroremptystreamsources', 'mod_opencast'), - \core\output\notification::NOTIFY_ERROR - )); - } - - // Replace video tag. - $text = preg_replace('/<video(?:(?!<\/video>).)*?' . preg_quote($match, '/') . '.*?<\/video>/s', - $newtext, $text, 1); - } - $width = $height = false; + if (empty($occurrences)) { + return $text; + } + + // Second section: splitting the text into tags (and stuff between tags), and search for relevant urls in <a> and <video>. + $matches = preg_split('/(<[^>]*>)/i', $text, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE); + if (!$matches) { + return $text; + } + + $i = 0; + $newtext = ''; + + $episode = null; + $currenttag = null; + $texttoreplace = ''; + $width = null; + $height = null; + + // We go through the complete text and transfer it match by match to $newtext. + // While we are going through interesting tags, $currenttag is set to 'video' or 'a' respectively. + // During that time, the matches are transferred to $texttoreplace instead. When we find the matching closing tag, + // ... we add either $texttoreplace to $newtext, or the html for the video player, if we found a matching opencast url. + foreach ($matches as $match) { + if ($currenttag) { + $texttoreplace .= $match; + if (self::str_starts_with($match, "</$currenttag")) { + $replacement = null; + if ($episode) { + $replacement = $this->render_player($episode[0], $episode[1], $i++, $width, $height); + } + if ($replacement) { + $newtext .= $replacement; + } else { + $newtext .= $texttoreplace; + } + $episode = null; + $width = null; + $height = null; + $texttoreplace = null; + $currenttag = null; + } else if (!$episode && $currenttag === 'video' && preg_match('/^<source\s/', $match)) { + $src = self::get_attribute($match, 'src'); + if ($src) { + $episode = self::test_url($src, $occurrences); + } + } + } else { + if (preg_match('/^<video[>\s]/', $match)) { + $currenttag = 'video'; + $width = self::get_attribute($match, 'width', '[0-9]+'); + $height = self::get_attribute($match, 'height', '[0-9]+'); + $src = self::get_attribute($match, 'src'); + if ($src) { + $episode = self::test_url($src, $occurrences); + } + } else if (preg_match('/^<a\s/', $match)) { + $src = self::get_attribute($match, 'href'); + if ($src) { + $episode = self::test_url($src, $occurrences); + // Only set currenttag if there is a recognized url, + // ... so that nested <a> or <video> tags can be matched otherwise. + if ($episode) { + $currenttag = 'a'; } } } + if ($currenttag) { + $texttoreplace .= $match; + } else { + $newtext .= $match; + } + } + } + + return $newtext; + } + + /** + * Render HTML for embedding video player. + * @param int $ocinstanceid Id of ocinstance. + * @param string $episodeid Id opencast episode. + * @param int $playerid Unique id to assign to player element. + * @param int|null $width Optionally width for player. + * @param int|null $height Optionally height for player. + * @return string|null + */ + protected function render_player(int $ocinstanceid, string $episodeid, int $playerid, + $width = null, $height = null): string|null { + global $OUTPUT, $PAGE; + + $data = paella_transform::get_paella_data_json($ocinstanceid, $episodeid); + + if (!$data) { + return null; + } + + // Collect the needed data being submitted to the template. + $mustachedata = new stdClass(); + $mustachedata->playerid = 'ocplayer_' . $playerid; + $mustachedata->configurl = + (new moodle_url(get_config('filter_opencast', 'configurl_' . $ocinstanceid)))->out(false); + $mustachedata->themeurl = + (new moodle_url(get_config('mod_opencast', 'themeurl_' . $ocinstanceid)))->out(false); + + $mustachedata->data = json_encode($data); + $mustachedata->width = $width; + $mustachedata->height = $height; + $mustachedata->modplayerpath = (new moodle_url('/mod/opencast/player.html'))->out(false); + + if (isset($data['streams'])) { + if (count($data['streams']) === 1) { + $sources = $data['streams'][0]['sources']; + $res = $sources[array_key_first($sources)][0]['res']; + $resolution = $res['w'] . '/' . $res['h']; + $mustachedata->resolution = $resolution; + + if ($width xor $height) { + if ($width) { + $mustachedata->height = $width * ($res['h'] / $res['w']); + } else if ($height) { + $mustachedata->width = $height * ($res['w'] / $res['h']); + } + } } + $renderer = $PAGE->get_renderer('filter_opencast'); + return $renderer->render_player($mustachedata); + } else { + return $OUTPUT->render(new \core\output\notification( + get_string('erroremptystreamsources', 'mod_opencast'), + \core\output\notification::NOTIFY_ERROR + )); } + } - // Return the same string except processed by the above. - return $text; + /** + * Polyfill for str_contains for PHP 7. + * @param string $haystack + * @param string $needle + * @return bool + */ + private static function str_contains(string $haystack, string $needle): bool { + return strpos($haystack, $needle) !== false; + } + + /** + * Polyfill for str_starts_with for PHP 7. + * @param string $haystack + * @param string $needle + * @return bool + */ + private static function str_starts_with(string $haystack, string $needle): bool { + return strpos($haystack, $needle) === 0; } } diff --git a/tests/replacement_test.php b/tests/replacement_test.php new file mode 100644 index 0000000000000000000000000000000000000000..19cd11174b7255ee73b629a4cb670c2124a2b1c3 --- /dev/null +++ b/tests/replacement_test.php @@ -0,0 +1,106 @@ +<?php +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see <http://www.gnu.org/licenses/>. + +/** + * Testcases for the opencast filter. + * + * @package filter_opencast + * @copyright 2024 Justus Dieckmann, University of Münster. + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace filter_opencast; +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/filter/opencast/tests/testable_filter.php'); + +/** + * Testcases for the opencast filter. + * + * @package filter_opencast + * @copyright 2024 Justus Dieckmann, University of Münster. + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @group filter_opencast + */ +class replacement_test extends \advanced_testcase { + + public function setUp(): void { + $this->resetAfterTest(); + set_config('episodeurl_1', "http://localhost:8080/play/[EPISODEID]\nhttps://stable.opencast.de/play/[EPISODEID]", + 'filter_opencast'); + } + + /** + * Actual test function. + * + * @dataProvider replacement_provider + * @covers \filter_opencast + * @param string $input input for filter + * @param string $output expected filter output. + */ + public function test_replacement($input, $output) { + $filter = new testable_filter(\context_system::instance(), []); + $this->assertEquals($output, $filter->filter($input)); + } + + /** + * Provides test cases. + */ + public function replacement_provider() { + return [ + [ + ' <p> hello </p> <video src="http://localhost:8080/play/f78ac136-8252-4b8e-bfea-4786c6993f03"> hello </video>', + ' <p> hello </p> <oc-video episode="f78ac136-8252-4b8e-bfea-4786c6993f03"/>' + ], + [ + '<video src="https://somethingother.com"></video><video> +<source +src="https://stable.opencast.de/play/370e5bef-1d59-4440-858a-4df62e767dfc"> +</video>', + '<video src="https://somethingother.com"></video><oc-video episode="370e5bef-1d59-4440-858a-4df62e767dfc"/>' + ], + [ + '<video +autoplay loopdiloop +src="http://localhost:8080/play/f9e7b289-c8be-462f-80bf-d1f493c6ed55"></video>', + '<oc-video episode="f9e7b289-c8be-462f-80bf-d1f493c6ed55"/>' + ], + [ + 'begin <video> +<source src="https://somethingother.de/play/4380f73a-47a6-41c6-b854-ec0fa9d0261b"> +<source +src="https://stable.opencast.de/play/2e0ca3bb-df8e-4913-9380-c925efaf5ac2"> +</video> end', + 'begin <oc-video episode="2e0ca3bb-df8e-4913-9380-c925efaf5ac2"/> end' + ], + [ + 'and a link <a href="https://www.google.com">link</a> +<a href="http://localhost:8080/play/09b9d154-c849-429d-adea-3df4f76429b6">look, a video!</a>', + 'and a link <a href="https://www.google.com">link</a> +<oc-video episode="09b9d154-c849-429d-adea-3df4f76429b6"/>' + ], + [ + 'and now two <a +href="http://localhost:8080/play/64b085e9-0142-4a10-a08e-3dbce055e740">look, a video!</a> +<video src="http://localhost:8080/play/329885fe-d18e-4c6b-a896-dbc66463a6b2"></video>.', + 'and now two <oc-video episode="64b085e9-0142-4a10-a08e-3dbce055e740"/> +<oc-video episode="329885fe-d18e-4c6b-a896-dbc66463a6b2"/>.' + ], + ]; + } + +} diff --git a/tests/testable_filter.php b/tests/testable_filter.php new file mode 100644 index 0000000000000000000000000000000000000000..2ddaed10157cf1ef44a68be42622913d79b1b9c3 --- /dev/null +++ b/tests/testable_filter.php @@ -0,0 +1,55 @@ +<?php +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see <http://www.gnu.org/licenses/>. + +/** + * Testable opencast filter. + * + * @package filter_opencast + * @copyright 2024 Justus Dieckmann, University of Münster. + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace filter_opencast; +defined('MOODLE_INTERNAL') || die(); + +global $CFG; + +require_once($CFG->dirroot . '/filter/opencast/filter.php'); + +/** + * Testable opencast filter. + * + * @package filter_opencast + * @copyright 2024 Justus Dieckmann, University of Münster. + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class testable_filter extends \filter_opencast { + + /** + * Render a simple + * @param int $ocinstanceid Id of ocinstance. + * @param string $episodeid Id opencast episode. + * @param int $playerid Unique id to assign to player element. + * @param int|null $width Optionally width for player. + * @param int|null $height Optionally height for player. + * @return string|null + */ + protected function render_player(int $ocinstanceid, string $episodeid, int $playerid, $width = null, + $height = null): string|null { + return '<oc-video episode="'. $episodeid . '"/>'; + } + +}