From 2f6fdf9091d52ca49709fc82621ba1c6dd0e817d Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Tue, 21 Dec 2021 16:25:03 +0000
Subject: [PATCH] feat(clips): setup clip entities and model + save video clip
 to have it generated in the background

---
 .../2021-12-09-130000_add_clips.php           |   7 +-
 app/Entities/BaseEntity.php                   |  11 --
 app/Entities/Clip.php                         |  44 -------
 app/Entities/Clip/BaseClip.php                | 119 ++++++++++++++++++
 app/Entities/Clip/Soundbite.php               |  16 +++
 app/Entities/Clip/VideoClip.php               |  29 +++++
 app/Entities/Episode.php                      |  19 +--
 app/Helpers/rss_helper.php                    |   8 +-
 .../{VideoClip.php => VideoClipper.php}       |   6 +-
 app/Models/ClipModel.php                      |  94 +++++++++++---
 .../Controllers/VideoClipsController.php      |  71 +++++++++--
 themes/cp_admin/episode/video_clips_list.php  |  20 +++
 12 files changed, 347 insertions(+), 97 deletions(-)
 delete mode 100644 app/Entities/BaseEntity.php
 delete mode 100644 app/Entities/Clip.php
 create mode 100644 app/Entities/Clip/BaseClip.php
 create mode 100644 app/Entities/Clip/Soundbite.php
 create mode 100644 app/Entities/Clip/VideoClip.php
 rename app/Libraries/MediaClipper/{VideoClip.php => VideoClipper.php} (99%)

diff --git a/app/Database/Migrations/2021-12-09-130000_add_clips.php b/app/Database/Migrations/2021-12-09-130000_add_clips.php
index 068c66b39d..e3319fb99a 100644
--- a/app/Database/Migrations/2021-12-09-130000_add_clips.php
+++ b/app/Database/Migrations/2021-12-09-130000_add_clips.php
@@ -51,10 +51,15 @@ class AddClips extends Migration
             'media_id' => [
                 'type' => 'INT',
                 'unsigned' => true,
+                'null' => true,
+            ],
+            'metadata' => [
+                'type' => 'JSON',
+                'null' => true,
             ],
             'status' => [
                 'type' => 'ENUM',
-                'constraint' => ['queued', 'pending', 'generating', 'passed', 'failed'],
+                'constraint' => ['queued', 'pending', 'running', 'passed', 'failed'],
             ],
             'logs' => [
                 'type' => 'TEXT',
diff --git a/app/Entities/BaseEntity.php b/app/Entities/BaseEntity.php
deleted file mode 100644
index b9f888a463..0000000000
--- a/app/Entities/BaseEntity.php
+++ /dev/null
@@ -1,11 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace App\Entities;
-
-use CodeIgniter\Entity\Entity;
-
-class BaseEntity extends Entity
-{
-}
diff --git a/app/Entities/Clip.php b/app/Entities/Clip.php
deleted file mode 100644
index 550cf40391..0000000000
--- a/app/Entities/Clip.php
+++ /dev/null
@@ -1,44 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Podlibre
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-namespace App\Entities;
-
-use CodeIgniter\Entity\Entity;
-
-/**
- * @property int $id
- * @property int $podcast_id
- * @property int $episode_id
- * @property double $start_time
- * @property double $duration
- * @property string|null $label
- * @property int $created_by
- * @property int $updated_by
- */
-class Clip extends Entity
-{
-    /**
-     * @var array<string, string>
-     */
-    protected $casts = [
-        'id' => 'integer',
-        'podcast_id' => 'integer',
-        'episode_id' => 'integer',
-        'start_time' => 'double',
-        'duration' => 'double',
-        'type' => 'string',
-        'label' => '?string',
-        'media_id' => 'integer',
-        'status' => 'string',
-        'logs' => 'string',
-        'created_by' => 'integer',
-        'updated_by' => 'integer',
-    ];
-}
diff --git a/app/Entities/Clip/BaseClip.php b/app/Entities/Clip/BaseClip.php
new file mode 100644
index 0000000000..c5d0e1a770
--- /dev/null
+++ b/app/Entities/Clip/BaseClip.php
@@ -0,0 +1,119 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Entities\Clip;
+
+use App\Entities\Episode;
+use App\Entities\Media\Audio;
+use App\Entities\Media\Video;
+use App\Entities\Podcast;
+use App\Models\EpisodeModel;
+use App\Models\MediaModel;
+use App\Models\PodcastModel;
+use CodeIgniter\Entity\Entity;
+use CodeIgniter\Files\File;
+
+/**
+ * @property int $id
+ * @property int $podcast_id
+ * @property Podcast $podcast
+ * @property int $episode_id
+ * @property Episode $episode
+ * @property string $label
+ * @property double $start_time
+ * @property double $end_time
+ * @property double $duration
+ * @property string $type
+ * @property int $media_id
+ * @property Video|Audio $media
+ * @property string $status
+ * @property string $logs
+ * @property int $created_by
+ * @property int $updated_by
+ */
+class BaseClip extends Entity
+{
+    /**
+     * @var array<string, string>
+     */
+    protected $casts = [
+        'id' => 'integer',
+        'podcast_id' => 'integer',
+        'episode_id' => 'integer',
+        'label' => 'string',
+        'start_time' => 'double',
+        'duration' => 'double',
+        'type' => 'string',
+        'media_id' => '?integer',
+        'metadata' => 'json-array',
+        'status' => 'string',
+        'logs' => 'string',
+        'created_by' => 'integer',
+        'updated_by' => 'integer',
+    ];
+
+    public function __construct($data)
+    {
+        parent::__construct($data);
+
+        if ($this->start_time && $this->duration) {
+            $this->end_time = $this->start_time + $this->duration;
+        } elseif ($this->start_time && $this->end_time) {
+            $this->duration = $this->end_time - $this->duration;
+        }
+    }
+
+    public function getPodcast(): ?Podcast
+    {
+        return (new PodcastModel())->getPodcastById($this->podcast_id);
+    }
+
+    public function getEpisode(): ?Episode
+    {
+        return (new EpisodeModel())->getEpisodeById($this->episode_id);
+    }
+
+    public function setMedia(string $filePath = null): static
+    {
+        if ($filePath === null || ($file = new File($filePath)) === null) {
+            return $this;
+        }
+
+        if ($this->media_id !== 0) {
+            $this->getMedia()
+                ->setFile($file);
+            $this->getMedia()
+                ->updated_by = (int) user_id();
+            (new MediaModel('audio'))->updateMedia($this->getMedia());
+        } else {
+            $media = new Audio([
+                'file_path' => $filePath,
+                'language_code' => $this->getPodcast()
+                    ->language_code,
+                'uploaded_by' => user_id(),
+                'updated_by' => user_id(),
+            ]);
+            $media->setFile($file);
+
+            $this->attributes['media_id'] = (new MediaModel())->saveMedia($media);
+        }
+
+        return $this;
+    }
+
+    public function getMedia(): Audio | Video
+    {
+        if ($this->media_id !== null && $this->media === null) {
+            $this->media = (new MediaModel($this->type))->getMediaById($this->media_id);
+        }
+
+        return $this->media;
+    }
+}
diff --git a/app/Entities/Clip/Soundbite.php b/app/Entities/Clip/Soundbite.php
new file mode 100644
index 0000000000..fea08e0915
--- /dev/null
+++ b/app/Entities/Clip/Soundbite.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Entities\Clip;
+
+class Soundbite extends BaseClip
+{
+    protected string $type = 'audio';
+}
diff --git a/app/Entities/Clip/VideoClip.php b/app/Entities/Clip/VideoClip.php
new file mode 100644
index 0000000000..a39b759f64
--- /dev/null
+++ b/app/Entities/Clip/VideoClip.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Entities\Clip;
+
+/**
+ * @property string $theme
+ */
+class VideoClip extends BaseClip
+{
+    protected string $type = 'video';
+
+    public function __construct(array $data = null)
+    {
+        parent::__construct($data);
+
+        if ($this->metadata !== null) {
+            $this->theme = $this->metadata['theme'];
+            $this->format = $this->metadata['format'];
+        }
+    }
+}
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index ca577f431b..2bc3fc784c 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -10,6 +10,7 @@ declare(strict_types=1);
 
 namespace App\Entities;
 
+use App\Entities\Clip\BaseClip;
 use App\Entities\Media\Audio;
 use App\Entities\Media\Chapters;
 use App\Entities\Media\Image;
@@ -74,7 +75,7 @@ use RuntimeException;
  * @property Time|null $deleted_at;
  *
  * @property Person[] $persons;
- * @property Clip[] $clips;
+ * @property Soundbites[] $soundbites;
  * @property string $embed_url;
  */
 class Episode extends Entity
@@ -109,9 +110,9 @@ class Episode extends Entity
     protected ?array $persons = null;
 
     /**
-     * @var Clip[]|null
+     * @var Soundbites[]|null
      */
-    protected ?array $clips = null;
+    protected ?array $soundbites = null;
 
     /**
      * @var Post[]|null
@@ -406,19 +407,19 @@ class Episode extends Entity
     /**
      * Returns the episode’s clips
      *
-     * @return Clip[]
+     * @return BaseClip[]|\App\Entities\Soundbites[]
      */
-    public function getClips(): array
+    public function getSoundbites(): array
     {
         if ($this->id === null) {
-            throw new RuntimeException('Episode must be created before getting clips.');
+            throw new RuntimeException('Episode must be created before getting soundbites.');
         }
 
-        if ($this->clips === null) {
-            $this->clips = (new ClipModel())->getEpisodeClips($this->getPodcast() ->id, $this->id);
+        if ($this->soundbites === null) {
+            $this->soundbites = (new ClipModel())->getEpisodeSoundbites($this->getPodcast()->id, $this->id);
         }
 
-        return $this->clips;
+        return $this->soundbites;
     }
 
     /**
diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php
index 076296b505..bee957f962 100644
--- a/app/Helpers/rss_helper.php
+++ b/app/Helpers/rss_helper.php
@@ -273,11 +273,11 @@ if (! function_exists('get_rss_feed')) {
                 $chaptersElement->addAttribute('type', 'application/json+chapters');
             }
 
-            foreach ($episode->clips as $clip) {
+            foreach ($episode->soundbites as $soundbite) {
                 // TODO: differentiate video from soundbites?
-                $soundbiteElement = $item->addChild('soundbite', $clip->label, $podcastNamespace);
-                $soundbiteElement->addAttribute('start_time', (string) $clip->start_time);
-                $soundbiteElement->addAttribute('duration', (string) $clip->duration);
+                $soundbiteElement = $item->addChild('soundbite', $soundbite->label, $podcastNamespace);
+                $soundbiteElement->addAttribute('start_time', (string) $soundbite->start_time);
+                $soundbiteElement->addAttribute('duration', (string) $soundbite->duration);
             }
 
             foreach ($episode->persons as $person) {
diff --git a/app/Libraries/MediaClipper/VideoClip.php b/app/Libraries/MediaClipper/VideoClipper.php
similarity index 99%
rename from app/Libraries/MediaClipper/VideoClip.php
rename to app/Libraries/MediaClipper/VideoClipper.php
index ad309feffd..6e223de294 100644
--- a/app/Libraries/MediaClipper/VideoClip.php
+++ b/app/Libraries/MediaClipper/VideoClipper.php
@@ -18,7 +18,7 @@ use GdImage;
  *
  * @phpstan-ignore-next-line
  */
-class VideoClip
+class VideoClipper
 {
     /**
      * @var array<string, string>
@@ -107,7 +107,7 @@ class VideoClip
         }
     }
 
-    public function generate(): void
+    public function generate(): string
     {
         $this->soundbite();
         $this->subtitlesClip();
@@ -119,7 +119,7 @@ class VideoClip
 
         $generateCmd = $this->getCmd();
 
-        shell_exec($generateCmd);
+        return shell_exec($generateCmd . ' 2>&1');
     }
 
     public function getCmd(): string
diff --git a/app/Models/ClipModel.php b/app/Models/ClipModel.php
index 3a1b8b2526..6ce42cc9cd 100644
--- a/app/Models/ClipModel.php
+++ b/app/Models/ClipModel.php
@@ -12,9 +12,13 @@ declare(strict_types=1);
 
 namespace App\Models;
 
-use App\Entities\Clip;
+use App\Entities\Clip\BaseClip;
+use App\Entities\Clip\Soundbite;
+use App\Entities\Clip\VideoClip;
 use CodeIgniter\Database\BaseResult;
+use CodeIgniter\Database\ConnectionInterface;
 use CodeIgniter\Model;
+use CodeIgniter\Validation\ValidationInterface;
 
 class ClipModel extends Model
 {
@@ -32,12 +36,16 @@ class ClipModel extends Model
      * @var string[]
      */
     protected $allowedFields = [
+        'id',
         'podcast_id',
         'episode_id',
         'label',
-        'type',
         'start_time',
         'duration',
+        'type',
+        'media_id',
+        'status',
+        'logs',
         'created_by',
         'updated_by',
     ];
@@ -45,7 +53,7 @@ class ClipModel extends Model
     /**
      * @var string
      */
-    protected $returnType = Clip::class;
+    protected $returnType = BaseClip::class;
 
     /**
      * @var bool
@@ -72,36 +80,93 @@ class ClipModel extends Model
      */
     protected $beforeDelete = ['clearCache'];
 
-    public function deleteClip(int $podcastId, int $episodeId, int $clipId): BaseResult | bool
-    {
-        return $this->delete([
-            'podcast_id' => $podcastId,
-            'episode_id' => $episodeId,
-            'id' => $clipId,
-        ]);
+    public function __construct(
+        protected string $type = 'audio',
+        ConnectionInterface &$db = null,
+        ValidationInterface $validation = null
+    ) {
+        // @phpstan-ignore-next-line
+        switch ($type) {
+            case 'audio':
+                $this->returnType = Soundbite::class;
+                break;
+            case 'video':
+                $this->returnType = VideoClip::class;
+                break;
+            default:
+                // do nothing, keep default class
+                break;
+        }
+
+        parent::__construct($db, $validation);
     }
 
     /**
      * Gets all clips for an episode
      *
-     * @return Clip[]
+     * @return BaseClip[]
      */
-    public function getEpisodeClips(int $podcastId, int $episodeId): array
+    public function getEpisodeSoundbites(int $podcastId, int $episodeId): array
     {
-        $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_clips";
+        $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_soundbites";
         if (! ($found = cache($cacheName))) {
             $found = $this->where([
                 'episode_id' => $episodeId,
                 'podcast_id' => $podcastId,
+                'type' => 'audio',
             ])
                 ->orderBy('start_time')
                 ->findAll();
+
+            foreach ($found as $key => $soundbite) {
+                $found[$key] = new Soundbite($soundbite->toArray());
+            }
+
             cache()
                 ->save($cacheName, $found, DECADE);
         }
         return $found;
     }
 
+    /**
+     * Gets all video clips for an episode
+     *
+     * @return BaseClip[]
+     */
+    public function getVideoClips(int $podcastId, int $episodeId): array
+    {
+        $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_video-clips";
+        if (! ($found = cache($cacheName))) {
+            $found = $this->where([
+                'episode_id' => $episodeId,
+                'podcast_id' => $podcastId,
+                'type' => 'video',
+            ])
+                ->orderBy('start_time')
+                ->findAll();
+
+            foreach ($found as $key => $videoClip) {
+                $found[$key] = new VideoClip($videoClip->toArray());
+            }
+
+            cache()
+                ->save($cacheName, $found, DECADE);
+        }
+        return $found;
+    }
+
+    public function deleteSoundbite(int $podcastId, int $episodeId, int $clipId): BaseResult | bool
+    {
+        cache()
+            ->delete("podcast#{$podcastId}_episode#{$episodeId}_soundbites");
+
+        return $this->delete([
+            'podcast_id' => $podcastId,
+            'episode_id' => $episodeId,
+            'id' => $clipId,
+        ]);
+    }
+
     /**
      * @param array<string, array<string|int, mixed>> $data
      * @return array<string, array<string|int, mixed>>
@@ -114,9 +179,6 @@ class ClipModel extends Model
                 : $data['id']['episode_id'],
         );
 
-        cache()
-            ->delete("podcast#{$episode->podcast_id}_episode#{$episode->id}_clips");
-
         // delete cache for rss feed
         cache()
             ->deleteMatching("podcast#{$episode->podcast_id}_feed*");
diff --git a/modules/Admin/Controllers/VideoClipsController.php b/modules/Admin/Controllers/VideoClipsController.php
index 3520e2862d..82be57b06d 100644
--- a/modules/Admin/Controllers/VideoClipsController.php
+++ b/modules/Admin/Controllers/VideoClipsController.php
@@ -10,13 +10,16 @@ declare(strict_types=1);
 
 namespace Modules\Admin\Controllers;
 
+use App\Entities\Clip;
+use App\Entities\Clip\VideoClip;
 use App\Entities\Episode;
 use App\Entities\Podcast;
+use App\Models\ClipModel;
 use App\Models\EpisodeModel;
 use App\Models\PodcastModel;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
-use MediaClipper\VideoClip;
+use MediaClipper\VideoClipper;
 
 class VideoClipsController extends BaseController
 {
@@ -57,9 +60,19 @@ class VideoClipsController extends BaseController
 
     public function list(): string
     {
+        $videoClips = (new ClipModel('video'))
+            ->where([
+                'podcast_id' => $this->podcast->id,
+                'episode_id' => $this->episode->id,
+                'type' => 'video',
+            ])
+            ->orderBy('created_at', 'desc');
+
         $data = [
             'podcast' => $this->podcast,
             'episode' => $this->episode,
+            'videoClips' => $videoClips->paginate(10),
+            'pager' => $videoClips->pager,
         ];
 
         replace_breadcrumb_params([
@@ -102,18 +115,58 @@ class VideoClipsController extends BaseController
                 ->with('errors', $this->validator->getErrors());
         }
 
-        $clipper = new VideoClip(
-            $this->episode,
-            (float) $this->request->getPost('start_time'),
-            (float) $this->request->getPost('end_time',),
-            $this->request->getPost('format'),
-            $this->request->getPost('theme'),
-        );
-        $clipper->generate();
+        $videoClip = new VideoClip([
+            'label' => 'NEW CLIP',
+            'start_time' => (float) $this->request->getPost('start_time'),
+            'end_time' => (float) $this->request->getPost('end_time',),
+            'type' => 'video',
+            'status' => 'queued',
+            'podcast_id' => $this->podcast->id,
+            'episode_id' => $this->episode->id,
+            'created_by' => user_id(),
+            'updated_by' => user_id(),
+        ]);
+
+        (new ClipModel())->insert($videoClip);
 
         return redirect()->route('video-clips-generate', [$this->podcast->id, $this->episode->id])->with(
             'message',
             lang('Settings.images.regenerationSuccess')
         );
     }
+
+    public function scheduleClips(): void
+    {
+        // get all clips that haven't been generated
+        $scheduledClips = (new ClipModel())->getScheduledVideoClips();
+
+        foreach ($scheduledClips as $scheduledClip) {
+            $scheduledClip->status = 'pending';
+        }
+
+        (new ClipModel())->updateBatch($scheduledClips);
+
+        // Loop through clips to generate them
+        foreach ($scheduledClips as $scheduledClip) {
+            // set clip to pending
+            (new ClipModel())
+                ->update($scheduledClip->id, [
+                    'status' => 'running',
+                ]);
+            $clipper = new VideoClipper(
+                $scheduledClip->episode,
+                $scheduledClip->start_time,
+                $scheduledClip->end_time,
+                $scheduledClip->format,
+                $scheduledClip->theme,
+            );
+            $output = $clipper->generate();
+            $scheduledClip->setMedia($clipper->videoClipOutput);
+
+            (new ClipModel())->update($scheduledClip->id, [
+                'status' => 'passed',
+                'logs' => $output,
+            ]);
+        }
+    }
 }
diff --git a/themes/cp_admin/episode/video_clips_list.php b/themes/cp_admin/episode/video_clips_list.php
index f357a62267..ee3ac24cdd 100644
--- a/themes/cp_admin/episode/video_clips_list.php
+++ b/themes/cp_admin/episode/video_clips_list.php
@@ -9,5 +9,25 @@
 <?= $this->endSection() ?>
 
 <?= $this->section('content') ?>
+<?= data_table(
+    [
+        [
+            'header' => lang('Episode.list.episode'),
+            'cell' => function ($videoClip): string {
+                return $videoClip->label;
+            },
+        ],
+        [
+            'header' => lang('Episode.list.visibility'),
+            'cell' => function ($videoClip): string {
+                return $videoClip->status;
+            },
+        ],
+    ],
+    $videoClips,
+    'mb-6'
+) ?>
+
+<?= $pager->links() ?>
 
 <?= $this->endSection() ?>
-- 
GitLab