Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
<?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/>.

namespace mod_bigbluebuttonbn;

use cache;
use context;
use context_course;
use context_module;
use core\persistent;
use mod_bigbluebuttonbn\local\proxy\recording_proxy;
use moodle_url;
use stdClass;

/**
 * The recording entity.
 *
 * This is utility class that defines a single recording, and provides methods for their local handling locally, and
 * communication with the bigbluebutton server.
 *
 * @package mod_bigbluebuttonbn
 * @copyright 2021 onwards, Blindside Networks Inc
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class recording extends persistent {
    /** The table name. */
    const TABLE = 'bigbluebuttonbn_recordings';

    /** @var int Defines that the activity used to create the recording no longer exists */
    public const RECORDING_HEADLESS = 1;

    /** @var int Defines that the recording is not the original but an imported one */
    public const RECORDING_IMPORTED = 1;

    /** @var int Defines that the list should include imported recordings */
    public const INCLUDE_IMPORTED_RECORDINGS = true;

    /** @var int A meeting set to be recorded still awaits for a recording update */
    public const RECORDING_STATUS_AWAITING = 0;

    /** @var int A meeting set to be recorded was not recorded and dismissed by BBB */
    public const RECORDING_STATUS_DISMISSED = 1;

    /** @var int A meeting set to be recorded has a recording processed */
    public const RECORDING_STATUS_PROCESSED = 2;

    /** @var int A meeting set to be recorded received notification callback from BBB */
    public const RECORDING_STATUS_NOTIFIED = 3;

    /** @var int A meeting set to be recorded was processed and set back to an awaiting state */
    public const RECORDING_STATUS_RESET = 4;

    /** @var int A meeting set to be recorded was deleted from bigbluebutton */
    public const RECORDING_STATUS_DELETED = 5;

    /** @var bool Whether metadata been changed so the remote information needs to be updated ? */
    protected $metadatachanged = false;

    /** @var int A refresh period for recordings, defaults to 300s (5mins) */
    public const RECORDING_REFRESH_DEFAULT_PERIOD = 300;

    /** @var int A time limit for recordings to be dismissed, defaults to 30d (30days) */
    public const RECORDING_TIME_LIMIT_DAYS = 30;

    /** @var array A cached copy of the metadata */
    protected $metadata = null;

    /** @var instance A cached copy of the instance */
    protected $instance;

> /** @var bool imported recording status */ /** > public $imported; * Create an instance of this class. >
* * @param int $id If set, this is the id of an existing record, used to load the data. * @param stdClass|null $record If set will be passed to from_record * @param null|array $metadata */ public function __construct($id = 0, stdClass $record = null, ?array $metadata = null) { if ($record) { $record->headless = $record->headless ?? false; $record->imported = $record->imported ?? false; $record->groupid = $record->groupid ?? 0; $record->status = $record->status ?? self::RECORDING_STATUS_AWAITING; } parent::__construct($id, $record); if ($metadata) { $this->metadata = $metadata; } } /** * Helper function to retrieve recordings from the BigBlueButton. * * @param instance $instance * @param bool $includeimported * @param bool $onlyimported * * @return recording[] containing the recordings indexed by recordID, each recording is also a * non sequential associative array itself that corresponds to the actual recording in BBB */ public static function get_recordings_for_instance( instance $instance, bool $includeimported = false, bool $onlyimported = false ): array { [$selects, $params] = self::get_basic_select_from_parameters(false, $includeimported, $onlyimported); $selects[] = "bigbluebuttonbnid = :bbbid"; $params['bbbid'] = $instance->get_instance_id(); $groupmode = groups_get_activity_groupmode($instance->get_cm()); $context = $instance->get_context(); if ($groupmode) { [$groupselects, $groupparams] = self::get_select_for_group( $groupmode, $context, $instance->get_course_id(), $instance->get_group_id(), $instance->get_cm()->groupingid ); if ($groupselects) { $selects[] = $groupselects; $params = array_merge_recursive($params, $groupparams); } } $recordings = self::fetch_records($selects, $params); foreach ($recordings as $recording) { $recording->instance = $instance; } return $recordings; } /** * Helper function to retrieve recordings from a given course. * * @param int $courseid id for a course record or null * @param array $excludedinstanceid exclude recordings from instance ids * @param bool $includeimported * @param bool $onlyimported * @param bool $includedeleted * @param bool $onlydeleted * * @return recording[] containing the recordings indexed by recordID, each recording is also a * non sequential associative array itself that corresponds to the actual recording in BBB */ public static function get_recordings_for_course( int $courseid, array $excludedinstanceid = [], bool $includeimported = false, bool $onlyimported = false, bool $includedeleted = false, bool $onlydeleted = false ): array { global $DB; [$selects, $params] = self::get_basic_select_from_parameters( $includedeleted, $includeimported, $onlyimported, $onlydeleted ); if ($courseid) { $selects[] = "courseid = :courseid"; $params['courseid'] = $courseid; $course = $DB->get_record('course', ['id' => $courseid]); $groupmode = groups_get_course_groupmode($course); $context = context_course::instance($courseid); } else { $context = \context_system::instance(); $groupmode = NOGROUPS; } if ($groupmode) { [$groupselects, $groupparams] = self::get_select_for_group($groupmode, $context, $course->id); if ($groupselects) { $selects[] = $groupselects; $params = array_merge($params, $groupparams); } } if ($excludedinstanceid) { [$sqlexcluded, $paramexcluded] = $DB->get_in_or_equal($excludedinstanceid, SQL_PARAMS_NAMED, 'param', false); $selects[] = "bigbluebuttonbnid {$sqlexcluded}"; $params = array_merge($params, $paramexcluded); } return self::fetch_records($selects, $params); } /** * Get select for given group mode and context * * @param int $groupmode
< * @param context $context
> * @param \context $context
* @param int $courseid * @param int $groupid * @param int $groupingid * @return array */ protected static function get_select_for_group($groupmode, $context, $courseid, $groupid = 0, $groupingid = 0): array { global $DB, $USER; $selects = []; $params = []; if ($groupmode) { $accessallgroups = has_capability('moodle/site:accessallgroups', $context) || $groupmode == VISIBLEGROUPS; if ($accessallgroups) { if ($context instanceof context_module) { $allowedgroups = groups_get_all_groups($courseid, 0, $groupingid); } else { $allowedgroups = groups_get_all_groups($courseid); } } else { if ($context instanceof context_module) { $allowedgroups = groups_get_all_groups($courseid, $USER->id, $groupingid); } else { $allowedgroups = groups_get_all_groups($courseid, $USER->id); } } $allowedgroupsid = array_map(function ($g) { return $g->id; }, $allowedgroups); if ($groupid || empty($allowedgroups)) { $selects[] = "groupid = :groupid"; $params['groupid'] = ($groupid && in_array($groupid, $allowedgroupsid)) ? $groupid : 0; } else { if ($accessallgroups) { $allowedgroupsid[] = 0; } list($groupselects, $groupparams) = $DB->get_in_or_equal($allowedgroupsid, SQL_PARAMS_NAMED); $selects[] = 'groupid ' . $groupselects; $params = array_merge_recursive($params, $groupparams); } } return [ implode(" AND ", $selects), $params, ]; } /** * Get basic sql select from given parameters * * @param bool $includedeleted * @param bool $includeimported * @param bool $onlyimported * @param bool $onlydeleted * @return array */ protected static function get_basic_select_from_parameters( bool $includedeleted = false, bool $includeimported = false, bool $onlyimported = false, bool $onlydeleted = false ): array { $selects = []; $params = []; // Start with the filters. if ($onlydeleted) { // Only headless recordings when only deleted is set. $selects[] = "headless = :headless"; $params['headless'] = self::RECORDING_HEADLESS; } else if (!$includedeleted) { // Exclude headless recordings unless includedeleted. $selects[] = "headless != :headless"; $params['headless'] = self::RECORDING_HEADLESS; } if (!$includeimported) { // Exclude imported recordings unless includedeleted. $selects[] = "imported != :imported"; $params['imported'] = self::RECORDING_IMPORTED; } else if ($onlyimported) { // Exclude non-imported recordings. $selects[] = "imported = :imported"; $params['imported'] = self::RECORDING_IMPORTED; } // Now get only recordings that have been validated by recording ready callback. $selects[] = "status IN (:status_processed, :status_notified)"; $params['status_processed'] = self::RECORDING_STATUS_PROCESSED; $params['status_notified'] = self::RECORDING_STATUS_NOTIFIED; return [$selects, $params]; } /** * Return the definition of the properties of this model. * * @return array */ protected static function define_properties() { return [ 'courseid' => [ 'type' => PARAM_INT, ], 'bigbluebuttonbnid' => [ 'type' => PARAM_INT, ], 'groupid' => [ 'type' => PARAM_INT, 'null' => NULL_ALLOWED, ], 'recordingid' => [ 'type' => PARAM_RAW, ], 'headless' => [ 'type' => PARAM_BOOL, ], 'imported' => [ 'type' => PARAM_BOOL, ], 'status' => [ 'type' => PARAM_INT, ], 'importeddata' => [ 'type' => PARAM_RAW, 'null' => NULL_ALLOWED, 'default' => '' ], 'name' => [ 'type' => PARAM_TEXT, 'null' => NULL_ALLOWED, 'default' => null ], 'description' => [ 'type' => PARAM_TEXT, 'null' => NULL_ALLOWED, 'default' => 0 ], 'protected' => [ 'type' => PARAM_BOOL, 'null' => NULL_ALLOWED, 'default' => null ], 'starttime' => [ 'type' => PARAM_INT, 'null' => NULL_ALLOWED, 'default' => null ], 'endtime' => [ 'type' => PARAM_INT, 'null' => NULL_ALLOWED, 'default' => null ], 'published' => [ 'type' => PARAM_BOOL, 'null' => NULL_ALLOWED, 'default' => null ], 'playbacks' => [ 'type' => PARAM_RAW, 'null' => NULL_ALLOWED, 'default' => null ], ]; } /** * Get the instance that this recording relates to. * * @return instance */ public function get_instance(): instance { if ($this->instance === null) { $this->instance = instance::get_from_instanceid($this->get('bigbluebuttonbnid')); } return $this->instance; } /** * Before doing the database update, let's check if we need to update metadata * * @return void */ protected function before_update() { // We update if the remote metadata has been changed locally. if ($this->metadatachanged && !$this->get('imported')) { $metadata = $this->fetch_metadata(); if ($metadata) { recording_proxy::update_recording( $this->get('recordingid'), $metadata ); } $this->metadatachanged = false; } } /** * Create a new imported recording from current recording * * @param instance $targetinstance * @return recording */ public function create_imported_recording(instance $targetinstance) { $recordingrec = $this->to_record(); $remotedata = $this->fetch_metadata(); unset($recordingrec->id); $recordingrec->bigbluebuttonbnid = $targetinstance->get_instance_id(); $recordingrec->courseid = $targetinstance->get_course_id(); $recordingrec->groupid = 0; // The recording is available to everyone. $recordingrec->importeddata = json_encode($remotedata); $recordingrec->imported = true; $recordingrec->headless = false; $importedrecording = new self(0, $recordingrec); $importedrecording->create(); return $importedrecording; } /** * Delete the recording in the BBB button * * @return void */ protected function before_delete() { $recordid = $this->get('recordingid'); if ($recordid && !$this->get('imported')) { recording_proxy::delete_recording($recordid); // Delete in cache if needed. $cachedrecordings = cache::make('mod_bigbluebuttonbn', 'recordings'); $cachedrecordings->delete($recordid); } } /** * Set name * * @param string $value */ protected function set_name($value) { $this->metadata_set('name', trim($value)); } /** * Set Description * * @param string $value */ protected function set_description($value) { $this->metadata_set('description', trim($value)); } /** * Recording is protected * * @param bool $value */ protected function set_protected($value) { $realvalue = $value ? "true" : "false"; $this->metadata_set('protected', $realvalue); recording_proxy::protect_recording($this->get('recordingid'), $realvalue); } /** * Recording starttime * * @param int $value */ protected function set_starttime($value) { $this->metadata_set('starttime', $value); } /** * Recording endtime * * @param int $value */ protected function set_endtime($value) { $this->metadata_set('endtime', $value); } /** * Recording is published * * @param bool $value */ protected function set_published($value) { $realvalue = $value ? "true" : "false"; $this->metadata_set('published', $realvalue); // Now set this flag onto the remote bbb server. recording_proxy::publish_recording($this->get('recordingid'), $realvalue); } /** * Update recording status * * @param bool $value */ protected function set_status($value) { $this->raw_set('status', $value); $this->update(); } /** * POSSIBLE_REMOTE_META_SOURCE match a field type and its metadataname (historical and current). */ const POSSIBLE_REMOTE_META_SOURCE = [ 'description' => ['meta_bbb-recording-description', 'meta_contextactivitydescription'], 'name' => ['meta_bbb-recording-name', 'meta_contextactivity', 'meetingName'], 'playbacks' => ['playbacks'], 'starttime' => ['startTime'], 'endtime' => ['endTime'], 'published' => ['published'], 'protected' => ['protected'], 'tags' => ['meta_bbb-recording-tags'] ]; /** * Get the real metadata name for the possible source. * * @param string $sourcetype the name of the source we look for (name, description...) * @param array $metadata current metadata */ protected function get_possible_meta_name_for_source($sourcetype, $metadata): string { $possiblesource = self::POSSIBLE_REMOTE_META_SOURCE[$sourcetype]; $possiblesourcename = $possiblesource[0]; foreach ($possiblesource as $possiblesname) { if (isset($meta[$possiblesname])) { $possiblesourcename = $possiblesname; } } return $possiblesourcename; } /** * Convert string (metadata) to json object * * @return mixed|null */ protected function remote_meta_convert() { $remotemeta = $this->raw_get('importeddata'); return json_decode($remotemeta, true); } /** * Description is stored in the metadata, so we sometimes needs to do some conversion. */ protected function get_description() { return trim($this->metadata_get('description')); } /** * Name is stored in the metadata */ protected function get_name() { return trim($this->metadata_get('name')); } /** * List of playbacks for this recording. * * @return array[] */ protected function get_playbacks() { if ($playbacks = $this->metadata_get('playbacks')) { return array_map(function (array $playback): array { $clone = array_merge([], $playback); $clone['url'] = new moodle_url('/mod/bigbluebuttonbn/bbb_view.php', [ 'action' => 'play', 'bn' => $this->raw_get('bigbluebuttonbnid'), 'rid' => $this->get('id'), 'rtype' => $clone['type'], ]); return $clone; }, $playbacks); } return []; } /** * Get the playback URL for the specified type. * * @param string $type * @return null|string */ public function get_remote_playback_url(string $type): ?string { $this->refresh_metadata_if_required(); $playbacks = $this->metadata_get('playbacks'); foreach ($playbacks as $playback) { if ($playback['type'] == $type) { return $playback['url']; } } return null; } /** * Is protected. Return null if protected is not implemented. * * @return bool|null */ protected function get_protected() { $protectedtext = $this->metadata_get('protected'); return is_null($protectedtext) ? null : $protectedtext === "true"; } /** * Start time * * @return mixed|null */ protected function get_starttime() { return $this->metadata_get('starttime'); } /** * Start time * * @return mixed|null */ protected function get_endtime() { return $this->metadata_get('endtime'); } /** * Is published * * @return bool */ protected function get_published() { $publishedtext = $this->metadata_get('published'); return $publishedtext === "true"; } /** * Set locally stored metadata from this instance * * @param string $fieldname * @param mixed $value */ protected function metadata_set($fieldname, $value) { // Can we can change the metadata on the imported record ? if ($this->get('imported')) { return; } $this->metadatachanged = true; $metadata = $this->fetch_metadata(); $possiblesourcename = $this->get_possible_meta_name_for_source($fieldname, $metadata); $metadata[$possiblesourcename] = $value; $this->metadata = $metadata; } /** * Get information stored in the recording metadata such as description, name and other info * * @param string $fieldname * @return mixed|null */ protected function metadata_get($fieldname) { $metadata = $this->fetch_metadata(); $possiblesourcename = $this->get_possible_meta_name_for_source($fieldname, $metadata); return $metadata[$possiblesourcename] ?? null; } /** * @var string Default sort for recordings when fetching from the database. */ const DEFAULT_RECORDING_SORT = 'timecreated ASC'; /** * Fetch all records which match the specified parameters, including all metadata that relates to them. * * @param array $selects * @param array $params * @return recording[] */ protected static function fetch_records(array $selects, array $params): array { global $DB, $CFG; $withindays = time() - (self::RECORDING_TIME_LIMIT_DAYS * DAYSECS); // Sort for recordings when fetching from the database. $recordingsort = $CFG->bigbluebuttonbn_recordings_asc_sort ? 'timecreated ASC' : 'timecreated DESC'; // Fetch the local data. Arbitrary sort by id, so we get the same result on different db engines. $recordings = $DB->get_records_select( static::TABLE, implode(" AND ", $selects), $params, $recordingsort ); // Grab the recording IDs. $recordingids = array_values(array_map(function ($recording) { return $recording->recordingid; }, $recordings)); // Fetch all metadata for these recordings. $metadatas = recording_proxy::fetch_recordings($recordingids); // Return the instances. return array_filter(array_map(function ($recording) use ($metadatas, $withindays) { // Filter out if no metadata was fetched. if (!array_key_exists($recording->recordingid, $metadatas)) { // Mark it as dismissed if it is older than 30 days. if ($withindays > $recording->timecreated) { $recording = new self(0, $recording, null); $recording->set_status(self::RECORDING_STATUS_DISMISSED); } return false; } $metadata = $metadatas[$recording->recordingid]; // Filter out and mark it as deleted if it was deleted in BBB. if ($metadata['state'] == 'deleted') { $recording = new self(0, $recording, null); $recording->set_status(self::RECORDING_STATUS_DELETED); return false; } // Include it otherwise. return new self(0, $recording, $metadata); }, $recordings)); } /** * Fetch metadata * * If metadata has changed locally or if it an imported recording, nothing will be done. * * @param bool $force * @return array */ protected function fetch_metadata(bool $force = false): ?array { if ($this->metadata !== null && !$force) { // Metadata is already up-to-date. return $this->metadata; } if ($this->get('imported')) { $this->metadata = json_decode($this->get('importeddata'), true); } else { $this->metadata = recording_proxy::fetch_recording($this->get('recordingid')); } return $this->metadata; } /** * Refresh metadata if required. * * If this is a protected recording which whose data was not fetched in the current request, then the metadata will * be purged and refetched. This ensures that the url is safe for use with a protected recording. */ protected function refresh_metadata_if_required() { recording_proxy::purge_protected_recording($this->get('recordingid')); $this->fetch_metadata(true); } /** * Synchronise pending recordings from the server. * * This function should be called by the check_pending_recordings scheduled task. * * @param bool $dismissedonly fetch dismissed recording only */ public static function sync_pending_recordings_from_server(bool $dismissedonly = false): void { global $DB; $params = [ 'withindays' => time() - (self::RECORDING_TIME_LIMIT_DAYS * DAYSECS), ]; // Fetch the local data. if ($dismissedonly) { mtrace("=> Looking for any recording that has been 'dismissed' in the past " . self::RECORDING_TIME_LIMIT_DAYS . " days."); $select = 'status = :status_dismissed AND timecreated > :withindays'; $params['status_dismissed'] = self::RECORDING_STATUS_DISMISSED; } else { mtrace("=> Looking for any recording awaiting processing from the past " . self::RECORDING_TIME_LIMIT_DAYS . " days."); $select = '(status = :status_awaiting AND timecreated > :withindays) OR status = :status_reset'; $params['status_reset'] = self::RECORDING_STATUS_RESET; $params['status_awaiting'] = self::RECORDING_STATUS_AWAITING; } $recordings = $DB->get_records_select(static::TABLE, $select, $params, self::DEFAULT_RECORDING_SORT); // Sort by DEFAULT_RECORDING_SORT we get the same result on different db engines. $recordingcount = count($recordings); mtrace("=> Found {$recordingcount} recordings to query"); // Grab the recording IDs. $recordingids = array_map(function($recording) { return $recording->recordingid; }, $recordings); // Fetch all metadata for these recordings. mtrace("=> Fetching recording metadata from server"); $metadatas = recording_proxy::fetch_recordings($recordingids); $foundcount = 0; foreach ($metadatas as $recordingid => $metadata) { mtrace("==> Found metadata for {$recordingid}."); $id = array_search($recordingid, $recordingids); if (!$id) { // Recording was not found, skip. mtrace("===> Skip as fetched recording was not found."); continue; } // Recording was found, update status. mtrace("===> Update local cache as fetched recording was found."); $recording = new self(0, $recordings[$id], $metadata); $recording->set_status(self::RECORDING_STATUS_PROCESSED); $foundcount++; if (array_key_exists('breakouts', $metadata)) { // Iterate breakout recordings (if any) and update status. foreach ($metadata['breakouts'] as $breakoutrecordingid => $breakoutmetadata) { $breakoutrecording = self::get_record(['recordingid' => $breakoutrecordingid]); if (!$breakoutrecording) { $breakoutrecording = new recording(0, (object) [ 'courseid' => $recording->get('courseid'), 'bigbluebuttonbnid' => $recording->get('bigbluebuttonbnid'), 'groupid' => $recording->get('groupid'), 'recordingid' => $breakoutrecordingid ], $breakoutmetadata); $breakoutrecording->create(); } $breakoutrecording->set_status(self::RECORDING_STATUS_PROCESSED); $foundcount++; } } } mtrace("=> Finished processing recordings. Updated status for {$foundcount} / {$recordingcount} recordings."); } }