Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

Differences Between: [Versions 400 and 402] [Versions 401 and 402]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  namespace mod_bigbluebuttonbn;
  18  
  19  use cache;
  20  use context;
  21  use context_course;
  22  use context_module;
  23  use core\persistent;
  24  use mod_bigbluebuttonbn\local\proxy\recording_proxy;
  25  use moodle_url;
  26  use stdClass;
  27  
  28  /**
  29   * The recording entity.
  30   *
  31   * This is utility class that defines a single recording, and provides methods for their local handling locally, and
  32   * communication with the bigbluebutton server.
  33   *
  34   * @package mod_bigbluebuttonbn
  35   * @copyright 2021 onwards, Blindside Networks Inc
  36   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  37   */
  38  class recording extends persistent {
  39      /** The table name. */
  40      const TABLE = 'bigbluebuttonbn_recordings';
  41  
  42      /** @var int Defines that the activity used to create the recording no longer exists */
  43      public const RECORDING_HEADLESS = 1;
  44  
  45      /** @var int Defines that the recording is not the original but an imported one */
  46      public const RECORDING_IMPORTED = 1;
  47  
  48      /** @var int Defines that the list should include imported recordings */
  49      public const INCLUDE_IMPORTED_RECORDINGS = true;
  50  
  51      /** @var int A meeting set to be recorded still awaits for a recording update */
  52      public const RECORDING_STATUS_AWAITING = 0;
  53  
  54      /** @var int A meeting set to be recorded was not recorded and dismissed by BBB */
  55      public const RECORDING_STATUS_DISMISSED = 1;
  56  
  57      /** @var int A meeting set to be recorded has a recording processed */
  58      public const RECORDING_STATUS_PROCESSED = 2;
  59  
  60      /** @var int A meeting set to be recorded received notification callback from BBB */
  61      public const RECORDING_STATUS_NOTIFIED = 3;
  62  
  63      /** @var int A meeting set to be recorded was processed and set back to an awaiting state */
  64      public const RECORDING_STATUS_RESET = 4;
  65  
  66      /** @var int A meeting set to be recorded was deleted from bigbluebutton */
  67      public const RECORDING_STATUS_DELETED = 5;
  68  
  69      /** @var bool Whether metadata been changed so the remote information needs to be updated ? */
  70      protected $metadatachanged = false;
  71  
  72      /** @var int A refresh period for recordings, defaults to 300s (5mins) */
  73      public const RECORDING_REFRESH_DEFAULT_PERIOD = 300;
  74  
  75      /** @var int A time limit for recordings to be dismissed, defaults to 30d (30days) */
  76      public const RECORDING_TIME_LIMIT_DAYS = 30;
  77  
  78      /** @var array A cached copy of the metadata */
  79      protected $metadata = null;
  80  
  81      /** @var instance A cached copy of the instance */
  82      protected $instance;
  83  
  84      /** @var bool imported recording status */
  85      public $imported;
  86  
  87      /**
  88       * Create an instance of this class.
  89       *
  90       * @param int $id If set, this is the id of an existing record, used to load the data.
  91       * @param stdClass|null $record If set will be passed to from_record
  92       * @param null|array $metadata
  93       */
  94      public function __construct($id = 0, stdClass $record = null, ?array $metadata = null) {
  95          if ($record) {
  96              $record->headless = $record->headless ?? false;
  97              $record->imported = $record->imported ?? false;
  98              $record->groupid = $record->groupid ?? 0;
  99              $record->status = $record->status ?? self::RECORDING_STATUS_AWAITING;
 100          }
 101          parent::__construct($id, $record);
 102  
 103          if ($metadata) {
 104              $this->metadata = $metadata;
 105          }
 106      }
 107  
 108      /**
 109       * Helper function to retrieve recordings from the BigBlueButton.
 110       *
 111       * @param instance $instance
 112       * @param bool $includeimported
 113       * @param bool $onlyimported
 114       *
 115       * @return recording[] containing the recordings indexed by recordID, each recording is also a
 116       * non sequential associative array itself that corresponds to the actual recording in BBB
 117       */
 118      public static function get_recordings_for_instance(
 119          instance $instance,
 120          bool $includeimported = false,
 121          bool $onlyimported = false
 122      ): array {
 123          [$selects, $params] = self::get_basic_select_from_parameters(false, $includeimported, $onlyimported);
 124          $selects[] = "bigbluebuttonbnid = :bbbid";
 125          $params['bbbid'] = $instance->get_instance_id();
 126          $groupmode = groups_get_activity_groupmode($instance->get_cm());
 127          $context = $instance->get_context();
 128          if ($groupmode) {
 129              [$groupselects, $groupparams] = self::get_select_for_group(
 130                  $groupmode,
 131                  $context,
 132                  $instance->get_course_id(),
 133                  $instance->get_group_id(),
 134                  $instance->get_cm()->groupingid
 135              );
 136              if ($groupselects) {
 137                  $selects[] = $groupselects;
 138                  $params = array_merge_recursive($params, $groupparams);
 139              }
 140          }
 141  
 142          $recordings = self::fetch_records($selects, $params);
 143          foreach ($recordings as $recording) {
 144              $recording->instance = $instance;
 145          }
 146  
 147          return $recordings;
 148      }
 149  
 150      /**
 151       * Helper function to retrieve recordings from a given course.
 152       *
 153       * @param int $courseid id for a course record or null
 154       * @param array $excludedinstanceid exclude recordings from instance ids
 155       * @param bool $includeimported
 156       * @param bool $onlyimported
 157       * @param bool $includedeleted
 158       * @param bool $onlydeleted
 159       *
 160       * @return recording[] containing the recordings indexed by recordID, each recording is also a
 161       * non sequential associative array itself that corresponds to the actual recording in BBB
 162       */
 163      public static function get_recordings_for_course(
 164          int $courseid,
 165          array $excludedinstanceid = [],
 166          bool $includeimported = false,
 167          bool $onlyimported = false,
 168          bool $includedeleted = false,
 169          bool $onlydeleted = false
 170      ): array {
 171          global $DB;
 172  
 173          [$selects, $params] = self::get_basic_select_from_parameters(
 174              $includedeleted,
 175              $includeimported,
 176              $onlyimported,
 177              $onlydeleted
 178          );
 179          if ($courseid) {
 180              $selects[] = "courseid = :courseid";
 181              $params['courseid'] = $courseid;
 182              $course = $DB->get_record('course', ['id' => $courseid]);
 183              $groupmode = groups_get_course_groupmode($course);
 184              $context = context_course::instance($courseid);
 185          } else {
 186              $context = \context_system::instance();
 187              $groupmode = NOGROUPS;
 188          }
 189  
 190          if ($groupmode) {
 191              [$groupselects, $groupparams] = self::get_select_for_group($groupmode, $context, $course->id);
 192              if ($groupselects) {
 193                  $selects[] = $groupselects;
 194                  $params = array_merge($params, $groupparams);
 195              }
 196          }
 197  
 198          if ($excludedinstanceid) {
 199              [$sqlexcluded, $paramexcluded] = $DB->get_in_or_equal($excludedinstanceid, SQL_PARAMS_NAMED, 'param', false);
 200              $selects[] = "bigbluebuttonbnid {$sqlexcluded}";
 201              $params = array_merge($params, $paramexcluded);
 202          }
 203  
 204          return self::fetch_records($selects, $params);
 205      }
 206  
 207      /**
 208       * Get select for given group mode and context
 209       *
 210       * @param int $groupmode
 211       * @param \context $context
 212       * @param int $courseid
 213       * @param int $groupid
 214       * @param int $groupingid
 215       * @return array
 216       */
 217      protected static function get_select_for_group($groupmode, $context, $courseid, $groupid = 0, $groupingid = 0): array {
 218          global $DB, $USER;
 219  
 220          $selects = [];
 221          $params = [];
 222          if ($groupmode) {
 223              $accessallgroups = has_capability('moodle/site:accessallgroups', $context) || $groupmode == VISIBLEGROUPS;
 224              if ($accessallgroups) {
 225                  if ($context instanceof context_module) {
 226                      $allowedgroups = groups_get_all_groups($courseid, 0, $groupingid);
 227                  } else {
 228                      $allowedgroups = groups_get_all_groups($courseid);
 229                  }
 230              } else {
 231                  if ($context instanceof context_module) {
 232                      $allowedgroups = groups_get_all_groups($courseid, $USER->id, $groupingid);
 233                  } else {
 234                      $allowedgroups = groups_get_all_groups($courseid, $USER->id);
 235                  }
 236              }
 237              $allowedgroupsid = array_map(function ($g) {
 238                  return $g->id;
 239              }, $allowedgroups);
 240              if ($groupid || empty($allowedgroups)) {
 241                  $selects[] = "groupid = :groupid";
 242                  $params['groupid'] = ($groupid && in_array($groupid, $allowedgroupsid)) ?
 243                      $groupid : 0;
 244              } else {
 245                  if ($accessallgroups) {
 246                      $allowedgroupsid[] = 0;
 247                  }
 248                  list($groupselects, $groupparams) = $DB->get_in_or_equal($allowedgroupsid, SQL_PARAMS_NAMED);
 249                  $selects[] = 'groupid ' . $groupselects;
 250                  $params = array_merge_recursive($params, $groupparams);
 251              }
 252          }
 253          return [
 254              implode(" AND ", $selects),
 255              $params,
 256          ];
 257      }
 258  
 259      /**
 260       * Get basic sql select from given parameters
 261       *
 262       * @param bool $includedeleted
 263       * @param bool $includeimported
 264       * @param bool $onlyimported
 265       * @param bool $onlydeleted
 266       * @return array
 267       */
 268      protected static function get_basic_select_from_parameters(
 269          bool $includedeleted = false,
 270          bool $includeimported = false,
 271          bool $onlyimported = false,
 272          bool $onlydeleted = false
 273      ): array {
 274          $selects = [];
 275          $params = [];
 276  
 277          // Start with the filters.
 278          if ($onlydeleted) {
 279              // Only headless recordings when only deleted is set.
 280              $selects[] = "headless = :headless";
 281              $params['headless'] = self::RECORDING_HEADLESS;
 282          } else if (!$includedeleted) {
 283              // Exclude headless recordings unless includedeleted.
 284              $selects[] = "headless != :headless";
 285              $params['headless'] = self::RECORDING_HEADLESS;
 286          }
 287  
 288          if (!$includeimported) {
 289              // Exclude imported recordings unless includedeleted.
 290              $selects[] = "imported != :imported";
 291              $params['imported'] = self::RECORDING_IMPORTED;
 292          } else if ($onlyimported) {
 293              // Exclude non-imported recordings.
 294              $selects[] = "imported = :imported";
 295              $params['imported'] = self::RECORDING_IMPORTED;
 296          }
 297  
 298          // Now get only recordings that have been validated by recording ready callback.
 299          $selects[] = "status IN (:status_processed, :status_notified)";
 300          $params['status_processed'] = self::RECORDING_STATUS_PROCESSED;
 301          $params['status_notified'] = self::RECORDING_STATUS_NOTIFIED;
 302          return [$selects, $params];
 303      }
 304  
 305      /**
 306       * Return the definition of the properties of this model.
 307       *
 308       * @return array
 309       */
 310      protected static function define_properties() {
 311          return [
 312              'courseid' => [
 313                  'type' => PARAM_INT,
 314              ],
 315              'bigbluebuttonbnid' => [
 316                  'type' => PARAM_INT,
 317              ],
 318              'groupid' => [
 319                  'type' => PARAM_INT,
 320                  'null' => NULL_ALLOWED,
 321              ],
 322              'recordingid' => [
 323                  'type' => PARAM_RAW,
 324              ],
 325              'headless' => [
 326                  'type' => PARAM_BOOL,
 327              ],
 328              'imported' => [
 329                  'type' => PARAM_BOOL,
 330              ],
 331              'status' => [
 332                  'type' => PARAM_INT,
 333              ],
 334              'importeddata' => [
 335                  'type' => PARAM_RAW,
 336                  'null' => NULL_ALLOWED,
 337                  'default' => ''
 338              ],
 339              'name' => [
 340                  'type' => PARAM_TEXT,
 341                  'null' => NULL_ALLOWED,
 342                  'default' => null
 343              ],
 344              'description' => [
 345                  'type' => PARAM_TEXT,
 346                  'null' => NULL_ALLOWED,
 347                  'default' => 0
 348              ],
 349              'protected' => [
 350                  'type' => PARAM_BOOL,
 351                  'null' => NULL_ALLOWED,
 352                  'default' => null
 353              ],
 354              'starttime' => [
 355                  'type' => PARAM_INT,
 356                  'null' => NULL_ALLOWED,
 357                  'default' => null
 358              ],
 359              'endtime' => [
 360                  'type' => PARAM_INT,
 361                  'null' => NULL_ALLOWED,
 362                  'default' => null
 363              ],
 364              'published' => [
 365                  'type' => PARAM_BOOL,
 366                  'null' => NULL_ALLOWED,
 367                  'default' => null
 368              ],
 369              'playbacks' => [
 370                  'type' => PARAM_RAW,
 371                  'null' => NULL_ALLOWED,
 372                  'default' => null
 373              ],
 374          ];
 375      }
 376  
 377      /**
 378       * Get the instance that this recording relates to.
 379       *
 380       * @return instance
 381       */
 382      public function get_instance(): instance {
 383          if ($this->instance === null) {
 384              $this->instance = instance::get_from_instanceid($this->get('bigbluebuttonbnid'));
 385          }
 386  
 387          return $this->instance;
 388      }
 389  
 390      /**
 391       * Before doing the database update, let's check if we need to update metadata
 392       *
 393       * @return void
 394       */
 395      protected function before_update() {
 396          // We update if the remote metadata has been changed locally.
 397          if ($this->metadatachanged && !$this->get('imported')) {
 398              $metadata = $this->fetch_metadata();
 399              if ($metadata) {
 400                  recording_proxy::update_recording(
 401                      $this->get('recordingid'),
 402                      $metadata
 403                  );
 404              }
 405              $this->metadatachanged = false;
 406          }
 407      }
 408  
 409      /**
 410       * Create a new imported recording from current recording
 411       *
 412       * @param instance $targetinstance
 413       * @return recording
 414       */
 415      public function create_imported_recording(instance $targetinstance) {
 416          $recordingrec = $this->to_record();
 417          $remotedata = $this->fetch_metadata();
 418          unset($recordingrec->id);
 419          $recordingrec->bigbluebuttonbnid = $targetinstance->get_instance_id();
 420          $recordingrec->courseid = $targetinstance->get_course_id();
 421          $recordingrec->groupid = 0; // The recording is available to everyone.
 422          $recordingrec->importeddata = json_encode($remotedata);
 423          $recordingrec->imported = true;
 424          $recordingrec->headless = false;
 425          $importedrecording = new self(0, $recordingrec);
 426          $importedrecording->create();
 427          return $importedrecording;
 428      }
 429  
 430      /**
 431       * Delete the recording in the BBB button
 432       *
 433       * @return void
 434       */
 435      protected function before_delete() {
 436          $recordid = $this->get('recordingid');
 437          if ($recordid && !$this->get('imported')) {
 438              recording_proxy::delete_recording($recordid);
 439              // Delete in cache if needed.
 440              $cachedrecordings = cache::make('mod_bigbluebuttonbn', 'recordings');
 441              $cachedrecordings->delete($recordid);
 442          }
 443      }
 444  
 445      /**
 446       * Set name
 447       *
 448       * @param string $value
 449       */
 450      protected function set_name($value) {
 451          $this->metadata_set('name', trim($value));
 452      }
 453  
 454      /**
 455       * Set Description
 456       *
 457       * @param string $value
 458       */
 459      protected function set_description($value) {
 460          $this->metadata_set('description', trim($value));
 461      }
 462  
 463      /**
 464       * Recording is protected
 465       *
 466       * @param bool $value
 467       */
 468      protected function set_protected($value) {
 469          $realvalue = $value ? "true" : "false";
 470          $this->metadata_set('protected', $realvalue);
 471          recording_proxy::protect_recording($this->get('recordingid'), $realvalue);
 472      }
 473  
 474      /**
 475       * Recording starttime
 476       *
 477       * @param int $value
 478       */
 479      protected function set_starttime($value) {
 480          $this->metadata_set('starttime', $value);
 481      }
 482  
 483      /**
 484       * Recording endtime
 485       *
 486       * @param int $value
 487       */
 488      protected function set_endtime($value) {
 489          $this->metadata_set('endtime', $value);
 490      }
 491  
 492      /**
 493       * Recording is published
 494       *
 495       * @param bool $value
 496       */
 497      protected function set_published($value) {
 498          $realvalue = $value ? "true" : "false";
 499          $this->metadata_set('published', $realvalue);
 500          // Now set this flag onto the remote bbb server.
 501          recording_proxy::publish_recording($this->get('recordingid'), $realvalue);
 502      }
 503  
 504      /**
 505       * Update recording status
 506       *
 507       * @param bool $value
 508       */
 509      protected function set_status($value) {
 510          $this->raw_set('status', $value);
 511          $this->update();
 512      }
 513  
 514      /**
 515       * POSSIBLE_REMOTE_META_SOURCE match a field type and its metadataname (historical and current).
 516       */
 517      const POSSIBLE_REMOTE_META_SOURCE = [
 518          'description' => ['meta_bbb-recording-description', 'meta_contextactivitydescription'],
 519          'name' => ['meta_bbb-recording-name', 'meta_contextactivity', 'meetingName'],
 520          'playbacks' => ['playbacks'],
 521          'starttime' => ['startTime'],
 522          'endtime' => ['endTime'],
 523          'published' => ['published'],
 524          'protected' => ['protected'],
 525          'tags' => ['meta_bbb-recording-tags']
 526      ];
 527  
 528      /**
 529       * Get the real metadata name for the possible source.
 530       *
 531       * @param string $sourcetype the name of the source we look for (name, description...)
 532       * @param array $metadata current metadata
 533       */
 534      protected function get_possible_meta_name_for_source($sourcetype, $metadata): string {
 535          $possiblesource = self::POSSIBLE_REMOTE_META_SOURCE[$sourcetype];
 536          $possiblesourcename = $possiblesource[0];
 537          foreach ($possiblesource as $possiblesname) {
 538              if (isset($meta[$possiblesname])) {
 539                  $possiblesourcename = $possiblesname;
 540              }
 541          }
 542          return $possiblesourcename;
 543      }
 544  
 545      /**
 546       * Convert string (metadata) to json object
 547       *
 548       * @return mixed|null
 549       */
 550      protected function remote_meta_convert() {
 551          $remotemeta = $this->raw_get('importeddata');
 552          return json_decode($remotemeta, true);
 553      }
 554  
 555      /**
 556       * Description is stored in the metadata, so we sometimes needs to do some conversion.
 557       */
 558      protected function get_description() {
 559          return trim($this->metadata_get('description'));
 560      }
 561  
 562      /**
 563       * Name is stored in the metadata
 564       */
 565      protected function get_name() {
 566          return trim($this->metadata_get('name'));
 567      }
 568  
 569      /**
 570       * List of playbacks for this recording.
 571       *
 572       * @return array[]
 573       */
 574      protected function get_playbacks() {
 575          if ($playbacks = $this->metadata_get('playbacks')) {
 576              return array_map(function (array $playback): array {
 577                  $clone = array_merge([], $playback);
 578                  $clone['url'] = new moodle_url('/mod/bigbluebuttonbn/bbb_view.php', [
 579                      'action' => 'play',
 580                      'bn' => $this->raw_get('bigbluebuttonbnid'),
 581                      'rid' => $this->get('id'),
 582                      'rtype' => $clone['type'],
 583                  ]);
 584  
 585                  return $clone;
 586              }, $playbacks);
 587          }
 588  
 589          return [];
 590      }
 591  
 592      /**
 593       * Get the playback URL for the specified type.
 594       *
 595       * @param string $type
 596       * @return null|string
 597       */
 598      public function get_remote_playback_url(string $type): ?string {
 599          $this->refresh_metadata_if_required();
 600  
 601          $playbacks = $this->metadata_get('playbacks');
 602          foreach ($playbacks as $playback) {
 603              if ($playback['type'] == $type) {
 604                  return $playback['url'];
 605              }
 606          }
 607  
 608          return null;
 609      }
 610  
 611      /**
 612       * Is protected. Return null if protected is not implemented.
 613       *
 614       * @return bool|null
 615       */
 616      protected function get_protected() {
 617          $protectedtext = $this->metadata_get('protected');
 618          return is_null($protectedtext) ? null : $protectedtext === "true";
 619      }
 620  
 621      /**
 622       * Start time
 623       *
 624       * @return mixed|null
 625       */
 626      protected function get_starttime() {
 627          return $this->metadata_get('starttime');
 628      }
 629  
 630      /**
 631       * Start time
 632       *
 633       * @return mixed|null
 634       */
 635      protected function get_endtime() {
 636          return $this->metadata_get('endtime');
 637      }
 638  
 639      /**
 640       * Is published
 641       *
 642       * @return bool
 643       */
 644      protected function get_published() {
 645          $publishedtext = $this->metadata_get('published');
 646          return $publishedtext === "true";
 647      }
 648  
 649      /**
 650       * Set locally stored metadata from this instance
 651       *
 652       * @param string $fieldname
 653       * @param mixed $value
 654       */
 655      protected function metadata_set($fieldname, $value) {
 656          // Can we can change the metadata on the imported record ?
 657          if ($this->get('imported')) {
 658              return;
 659          }
 660  
 661          $this->metadatachanged = true;
 662  
 663          $metadata = $this->fetch_metadata();
 664          $possiblesourcename = $this->get_possible_meta_name_for_source($fieldname, $metadata);
 665          $metadata[$possiblesourcename] = $value;
 666  
 667          $this->metadata = $metadata;
 668      }
 669  
 670      /**
 671       * Get information stored in the recording metadata such as description, name and other info
 672       *
 673       * @param string $fieldname
 674       * @return mixed|null
 675       */
 676      protected function metadata_get($fieldname) {
 677          $metadata = $this->fetch_metadata();
 678  
 679          $possiblesourcename = $this->get_possible_meta_name_for_source($fieldname, $metadata);
 680          return $metadata[$possiblesourcename] ?? null;
 681      }
 682  
 683      /**
 684       * @var string Default sort for recordings when fetching from the database.
 685       */
 686      const DEFAULT_RECORDING_SORT = 'timecreated ASC';
 687  
 688      /**
 689       * Fetch all records which match the specified parameters, including all metadata that relates to them.
 690       *
 691       * @param array $selects
 692       * @param array $params
 693       * @return recording[]
 694       */
 695      protected static function fetch_records(array $selects, array $params): array {
 696          global $DB, $CFG;
 697  
 698          $withindays = time() - (self::RECORDING_TIME_LIMIT_DAYS * DAYSECS);
 699          // Sort for recordings when fetching from the database.
 700          $recordingsort = $CFG->bigbluebuttonbn_recordings_asc_sort ? 'timecreated ASC' : 'timecreated DESC';
 701  
 702          // Fetch the local data. Arbitrary sort by id, so we get the same result on different db engines.
 703          $recordings = $DB->get_records_select(
 704              static::TABLE,
 705              implode(" AND ", $selects),
 706              $params,
 707              $recordingsort
 708          );
 709  
 710          // Grab the recording IDs.
 711          $recordingids = array_values(array_map(function ($recording) {
 712              return $recording->recordingid;
 713          }, $recordings));
 714  
 715          // Fetch all metadata for these recordings.
 716          $metadatas = recording_proxy::fetch_recordings($recordingids);
 717  
 718          // Return the instances.
 719          return array_filter(array_map(function ($recording) use ($metadatas, $withindays) {
 720              // Filter out if no metadata was fetched.
 721              if (!array_key_exists($recording->recordingid, $metadatas)) {
 722                  // Mark it as dismissed if it is older than 30 days.
 723                  if ($withindays > $recording->timecreated) {
 724                      $recording = new self(0, $recording, null);
 725                      $recording->set_status(self::RECORDING_STATUS_DISMISSED);
 726                  }
 727                  return false;
 728              }
 729              $metadata = $metadatas[$recording->recordingid];
 730              // Filter out and mark it as deleted if it was deleted in BBB.
 731              if ($metadata['state'] == 'deleted') {
 732                  $recording = new self(0, $recording, null);
 733                  $recording->set_status(self::RECORDING_STATUS_DELETED);
 734                  return false;
 735              }
 736              // Include it otherwise.
 737              return new self(0, $recording, $metadata);
 738          }, $recordings));
 739      }
 740  
 741      /**
 742       * Fetch metadata
 743       *
 744       * If metadata has changed locally or if it an imported recording, nothing will be done.
 745       *
 746       * @param bool $force
 747       * @return array
 748       */
 749      protected function fetch_metadata(bool $force = false): ?array {
 750          if ($this->metadata !== null && !$force) {
 751              // Metadata is already up-to-date.
 752              return $this->metadata;
 753          }
 754  
 755          if ($this->get('imported')) {
 756              $this->metadata = json_decode($this->get('importeddata'), true);
 757          } else {
 758              $this->metadata = recording_proxy::fetch_recording($this->get('recordingid'));
 759          }
 760  
 761          return $this->metadata;
 762      }
 763  
 764      /**
 765       * Refresh metadata if required.
 766       *
 767       * If this is a protected recording which whose data was not fetched in the current request, then the metadata will
 768       * be purged and refetched. This ensures that the url is safe for use with a protected recording.
 769       */
 770      protected function refresh_metadata_if_required() {
 771          recording_proxy::purge_protected_recording($this->get('recordingid'));
 772          $this->fetch_metadata(true);
 773      }
 774  
 775      /**
 776       * Synchronise pending recordings from the server.
 777       *
 778       * This function should be called by the check_pending_recordings scheduled task.
 779       *
 780       * @param bool $dismissedonly fetch dismissed recording only
 781       */
 782      public static function sync_pending_recordings_from_server(bool $dismissedonly = false): void {
 783          global $DB;
 784          $params = [
 785              'withindays' => time() - (self::RECORDING_TIME_LIMIT_DAYS * DAYSECS),
 786          ];
 787          // Fetch the local data.
 788          if ($dismissedonly) {
 789              mtrace("=> Looking for any recording that has been 'dismissed' in the past " . self::RECORDING_TIME_LIMIT_DAYS
 790                  . " days.");
 791              $select = 'status = :status_dismissed AND timecreated > :withindays';
 792              $params['status_dismissed'] = self::RECORDING_STATUS_DISMISSED;
 793          } else {
 794              mtrace("=> Looking for any recording awaiting processing from the past " . self::RECORDING_TIME_LIMIT_DAYS . " days.");
 795              $select = '(status = :status_awaiting AND timecreated > :withindays) OR status = :status_reset';
 796              $params['status_reset'] = self::RECORDING_STATUS_RESET;
 797              $params['status_awaiting'] = self::RECORDING_STATUS_AWAITING;
 798          }
 799  
 800          $recordings = $DB->get_records_select(static::TABLE, $select, $params, self::DEFAULT_RECORDING_SORT);
 801          // Sort by DEFAULT_RECORDING_SORT we get the same result on different db engines.
 802  
 803          $recordingcount = count($recordings);
 804          mtrace("=> Found {$recordingcount} recordings to query");
 805  
 806          // Grab the recording IDs.
 807          $recordingids = array_map(function($recording) {
 808              return $recording->recordingid;
 809          }, $recordings);
 810  
 811          // Fetch all metadata for these recordings.
 812          mtrace("=> Fetching recording metadata from server");
 813          $metadatas = recording_proxy::fetch_recordings($recordingids);
 814  
 815          $foundcount = 0;
 816          foreach ($metadatas as $recordingid => $metadata) {
 817              mtrace("==> Found metadata for {$recordingid}.");
 818              $id = array_search($recordingid, $recordingids);
 819              if (!$id) {
 820                  // Recording was not found, skip.
 821                  mtrace("===> Skip as fetched recording was not found.");
 822                  continue;
 823              }
 824              // Recording was found, update status.
 825              mtrace("===> Update local cache as fetched recording was found.");
 826              $recording = new self(0, $recordings[$id], $metadata);
 827              $recording->set_status(self::RECORDING_STATUS_PROCESSED);
 828              $foundcount++;
 829  
 830              if (array_key_exists('breakouts', $metadata)) {
 831                  // Iterate breakout recordings (if any) and update status.
 832                  foreach ($metadata['breakouts'] as $breakoutrecordingid => $breakoutmetadata) {
 833                      $breakoutrecording = self::get_record(['recordingid' => $breakoutrecordingid]);
 834                      if (!$breakoutrecording) {
 835                          $breakoutrecording = new recording(0, (object) [
 836                              'courseid' => $recording->get('courseid'),
 837                              'bigbluebuttonbnid' => $recording->get('bigbluebuttonbnid'),
 838                              'groupid' => $recording->get('groupid'),
 839                              'recordingid' => $breakoutrecordingid
 840                          ], $breakoutmetadata);
 841                          $breakoutrecording->create();
 842                      }
 843                      $breakoutrecording->set_status(self::RECORDING_STATUS_PROCESSED);
 844                      $foundcount++;
 845                  }
 846              }
 847          }
 848  
 849          mtrace("=> Finished processing recordings. Updated status for {$foundcount} / {$recordingcount} recordings.");
 850      }
 851  }