cloudflare_stream-8.x-1.0/src/StreamWrapper/CloudflareStreamWrapper.php

src/StreamWrapper/CloudflareStreamWrapper.php
<?php

declare(strict_types=1);

namespace Drupal\cloudflare_stream\StreamWrapper;

use Drupal\cloudflare_stream\Service\CloudflareStream;
use Drupal\cloudflare_stream\Service\CloudflareStreamApi;
use Drupal\cloudflare_stream\Tus\UploadClient;
use Drupal\Component\Datetime\DateTimePlus;
use Drupal\Core\StreamWrapper\LocalStream;
use Drupal\Core\StreamWrapper\StreamWrapperInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\TempStore\PrivateTempStore;
use Drupal\Core\TempStore\PrivateTempStoreFactory;

/**
 * Uses LocalStream for write operations and Cloudflare API for read.
 *
 * New file upload flow:
 * - File is selected in the widget
 * - ::url_stat() [prepareDirectory]
 * - ::url_stat() [file_exists]
 * - ::stream_open('wb')
 * - ::stream_write()
 * - ::stream_flush()
 * - ::stream_close() [File is uploaded]
 * - ::url_stat() [File meta-data]
 * - ::stream_metadata() [chmod]
 * - ::url_stat() [file size]
 */
final class CloudflareStreamWrapper extends LocalStream {
  use StringTranslationTrait;

  /**
   * Instance URI (stream).
   *
   * This stream is read as "cfstream://{video_id}".
   * Demo: cfstream://1abc234de567f8901234g5678hi90j12
   * It is written to as: cfstream://{source_file}
   * Demo: cfstream://vid.mp4
   *
   * The StreamWrapper interface does not provide a means to alter the uri and
   * the video ID will not be available until after the file is uploaded
   * to Cloudflare.  The video id is temporarily stored in ::stream_close()
   * and the uri is reset in CloudflareVideoItem::preSave.
   *
   * @var string
   *
   * @see \Drupal\cloudflare_stream\Plugin\Field\FieldType\CloudflareVideoItem::preSave()
   * @see self::stream_close()
   */
  protected $uri;

  /**
   * Retrieved video information.
   *
   * @var mixed[]
   */
  protected array $videos;

  /**
   * The current index of the read position.
   *
   * @see self::dir_readdir()
   * @see self::dir_rewinddir()
   */
  protected int $videoPointer;

  /**
   * Total number of videos stored in Cloudflare.
   */
  protected int $videosAvailable;

  /**
   * Date of the oldest video info retrieved.
   */
  protected DateTimePlus $loadedThrough;

  /**
   * The mode in which the stream was opened.
   */
  protected string $mode;

  /**
   * Account data.
   */
  protected CloudflareStream $cloudflareStream;

  /**
   * API service.
   */
  protected CloudflareStreamApi $cfStreamApi;

  /**
   * Key-value store to hold video ID for our field type.
   */
  protected PrivateTempStore $tempStore;

  public function __construct(
    ?CloudflareStream $cloudflareStream = NULL,
    ?CloudflareStreamApi $cloudflareStreamApi = NULL,
    ?PrivateTempStoreFactory $tempStoreFactory = NULL,
    protected ?UploadClient $client = NULL,
  ) {
    /* Dependency injection will not work here, since PHP doesn't give us a
     * chance to perform the injection. PHP creates the stream wrapper objects
     * automatically when certain file functions are called. Instead we'll use
     * the \Drupal service locator. However, we've also included nullable
     * parameters on the constructor so that this wrapper can be tested.
     */

    // phpcs:ignore DrupalPractice.Objects.GlobalDrupal.GlobalDrupal
    $this->cloudflareStream = $cloudflareStream ?? \Drupal::service('cloudflare_stream');
    $this->cfStreamApi = $cloudflareStreamApi ?? \Drupal::service('cloudflare_stream.api');
    $factory = $tempStoreFactory ?? \Drupal::service('tempstore.private');
    $this->tempStore = $factory->get('cloudflare_stream');
  }

  /* ==== Helper methods ==== */

  /**
   * Checks a file id in Cloudflare.
   *
   * @return bool
   *   True if the id returns video details.
   */
  protected function isRemoteFileId(?string $path = NULL): bool {
    $path = $path ?? $this->uri;
    $details = [];
    $id = $this->getIdFromUri($path);
    if (!empty($id)) {
      $details = $this->cfStreamApi->getDetails($id);
    }
    return $details !== [];
  }

  /**
   * Parse the URI and get the ID (first slug) if present.
   *
   * @param string|null $path
   *   Option path.  Uses $this->uri if omitted.
   *
   * @return string
   *   ID or empty string if missing.
   */
  protected function getIdFromUri(?string $path = NULL): string {
    $path = $path ?? $this->uri;
    $id = parse_url($path, PHP_URL_HOST);
    if ($id === FALSE) {
      // There was not host property.
      $id = '';
    }
    return $id;
  }

  /**
   * A testing client may already be injected, normally we create one.
   *
   * @param string $uploadUrl
   *   The upload URL obtained from Cloudflare.
   * @param string $filePath
   *   The file path to the source file.
   */
  protected function configureUploadClient(string $uploadUrl, string $filePath) {
    if (is_null($this->client)) {
      $logger = \Drupal::logger('cloudflare_stream');
      $this->client = new UploadClient($uploadUrl, $filePath, $this->cloudflareStream->getApiToken(), $logger);
    }
  }

  /* ==== \Drupal\Core\StreamWrapper\StreamWrapperInterface methods ==== */

  /**
   * {@inheritdoc}
   */
  public static function getType() {
    return StreamWrapperInterface::WRITE_VISIBLE;
  }

  /**
   * {@inheritdoc}
   */
  public function getDirectoryPath() {
    return \Drupal::service('file_system')->getTempDirectory();
  }

  /**
   * {@inheritdoc}
   */
  public function getName() {
    return $this->t('Cloudflare Stream');
  }

  /**
   * {@inheritdoc}
   */
  public function getDescription() {
    return $this->t('Videos served by the Cloudflare Stream service.');
  }

  /**
   * {@inheritdoc}
   */
  public function getExternalUrl() {
    $id = $this->getIdFromUri();
    $subdomain = $this->cloudflareStream->getCustomerSubdomain();

    if (empty($subdomain) || empty($id)) {
      return '';
    }
    return "https://$subdomain.cloudflarestream.com/$id/watch";
  }

  /**
   * {@inheritdoc}
   */
  public function dirname($uri = NULL) {
    $path = $this->getLocalPath($uri ?? $this->uri);
    if ($path === FALSE) {
      return FALSE;
    }
    return parent::dirname($uri);
  }

  /* The PhpStreamWrapperInterface calls for method names that do not conform
   * to Drupal standards.
   */

  // phpcs:disable Drupal.NamingConventions.ValidFunctionName.ScopeNotCamelCaps

  /**
   * {@inheritdoc}
   */
  public function dir_closedir() {
    $this->videoPointer = 0;
    $this->videos = [];
    $this->videosAvailable = 0;
    $this->loadedThrough = new DateTimePlus('@0');
    return TRUE;
  }

  /**
   * {@inheritdoc}
   */
  public function dir_opendir($path = '', $options = NULL) {
    // Reset and initialize.
    $videos = $this->cfStreamApi->listVideos(asc: TRUE);
    $successful = $videos['success'] ?? FALSE;
    if ($successful) {
      $this->videoPointer = 0;
      $this->videos = $videos['result'];
      $this->videosAvailable = $videos['total'];
      $last = end($this->videos);
      $this->loadedThrough = new DateTimePlus($last['created']);
      return TRUE;
    }
    return FALSE;
  }

  /**
   * {@inheritdoc}
   */
  public function dir_readdir() {
    if ($this->videoPointer < count($this->videos)) {
      $next = $this->videos[$this->videoPointer];
      $this->videoPointer++;
      return $next;
    }
    elseif ($this->videoPointer < $this->videosAvailable) {
      $after = $this->loadedThrough->add(\DateInterval::createFromDateString('1 second'));
      $videos = $this->cfStreamApi->listVideos(
        after: $after->format(DATE_ATOM),
        asc: TRUE,
      );
      $successful = $videos['success'] ?? FALSE;
      if ($successful && is_array($videos['result'])) {
        array_push($this->videos, $videos['result']);
        $this->videosAvailable = $videos['total'];
        $last = end($this->videos);
        $this->loadedThrough = new DateTimePlus($last['created']);
        $next = $this->videos[$this->videoPointer];
        $this->videoPointer++;
        return $next;
      }
    }
    return FALSE;
  }

  /**
   * {@inheritdoc}
   */
  public function dir_rewinddir() {
    $this->videoPointer = 0;
    return TRUE;
  }

  /**
   * {@inheritdoc}
   */
  public function mkdir($path, $mode, $options) {
    trigger_error('mkdir() not supported for CloudflareStreamWrapper', E_USER_WARNING);
    return FALSE;
  }

  /**
   * {@inheritdoc}
   */
  public function rename($path_from, $path_to) {
    trigger_error('rename() not supported for CloudflareStreamWrapper', E_USER_WARNING);
    return FALSE;
  }

  /**
   * {@inheritdoc}
   */
  public function rmdir($path, $options) {
    trigger_error('rmdir() not supported for CloudflareStreamWrapper', E_USER_WARNING);
    return FALSE;
  }

  /**
   * {@inheritdoc}
   */
  public function stream_cast($cast_as) {
    return FALSE;
  }

  /**
   * {@inheritdoc}
   */
  public function stream_open($path, $mode, $options, &$opened_path) {
    $this->uri = $path;
    // We are simply passing through data and ignore suffix characters.
    $this->mode = rtrim($mode, 'bt');
    return match ($this->mode) {
      'r' => $this->isRemoteFileId() || parent::stream_open($path, $mode, $options, $opened_path),
      'w' => parent::stream_open($path, $mode, $options, $opened_path),
      default => FALSE,
    };
  }

  /**
   * {@inheritdoc}
   */
  public function stream_close() {
    $closed = parent::stream_close();
    if ($this->mode !== 'w') {
      return TRUE;
    }
    $path = $this->getLocalPath();
    // Get size.
    $info = @stat($path);
    $size = $info['size'] ?? 1;
    $name = base64_encode($this->getIdFromUri());
    $parameters = $this->cfStreamApi->initiateTusUpload(
      length: $size,
      metaData: "name $name",
    );
    // Get an upload url and id.
    if ($closed && $parameters->ready) {
      $videoId = $parameters->videoId;
      // Configure the upload client.
      $this->configureUploadClient($parameters->destinationUrl, $path);
      $this->client->upload();

      // Store the video ID for use by the field widget.
      $this->tempStore->set($this->uri, $videoId);
      // Remove the uploaded file.
      $this->unlink($this->uri);
    }

    // Clean up.
    $this->uri = '';
    $this->mode = '';
    return $closed;
  }

  /**
   * {@inheritdoc}
   */
  public function stream_metadata($path, $option, $value) {
    if ($this->isRemoteFileId($path)) {
      // $path is not a local file name.
      return FALSE;
    }
    return parent::stream_metadata($path, $option, $value);
  }

  /**
   * {@inheritdoc}
   */
  public function stream_read($count) {
    return $this->cfStreamApi->getDetails($this->getIdFromUri());
  }

  /**
   * {@inheritdoc}
   */
  public function stream_set_option($option, $arg1, $arg2) {
    trigger_error('stream_set_option() not supported for CloudflareStreamWrapper', E_USER_WARNING);
    return FALSE;
  }

  /**
   * {@inheritdoc}
   */
  public function stream_stat() {
    $id = $this->getIdFromUri();

    if (empty($id) && !is_null($this->handle)) {
      // Asking for root - pass to parent.
      return fstat($this->handle);
    }
    else {
      // Full path given, get data from Cloudflare.
      // Treat as file that is readable if data is returned.
      // Otherwise return parent.
      $mode = 0100000 | 0444;
      $info = $this->cfStreamApi->getDetails($id);
      if (empty($info)) {
        return is_null($this->handle) ? FALSE : fstat($this->handle);
      }
      $size = $info['size'];
    }

    $stat = array_fill(0, 13, 0);
    $stat['dev'] = 0;
    $stat['ino'] = 0;
    $stat[2] = $stat['mode'] = $mode;
    $stat['nlink'] = 0;
    $stat['uid'] = 0;
    $stat['gid'] = 0;
    $stat['rdev'] = 0;
    $stat[7] = $stat['size'] = $size;
    $stat['atime'] = 0;
    $stat['mtime'] = 0;
    $stat['ctime'] = 0;
    $stat['blksize'] = 0;
    $stat['blocks'] = 0;

    return $stat;
  }

  /**
   * {@inheritdoc}
   */
  public function unlink($uri) {
    $target = $this->getLocalPath($uri);
    if ($target === FALSE) {
      // $path is no longer local.
      return $this->cfStreamApi->deleteVideo($this->getIdFromUri($uri));
    }
    return parent::unlink($uri);
  }

  /**
   * {@inheritdoc}
   */
  public function url_stat($uri, $flags) {
    $this->uri = $uri;
    $path = $this->getLocalPath();
    $isLocal = $path !== FALSE;
    if ($flags & STREAM_URL_STAT_QUIET) {
      return $isLocal ? @stat($path) : @$this->stream_stat();
    }
    else {
      return $isLocal ? stat($path) : $this->stream_stat();
    }
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc