From 3143c9ad36e4cf1364205cf2be39c0c96f80fdd2 Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Mon, 3 May 2021 17:39:58 +0000
Subject: [PATCH] feat: add remote_url alternative for transcript and chapters
 files

---
 .gitlab-ci.yml                                |   2 +-
 app/Config/Analytics.php                      |   4 +-
 app/Controllers/Admin/Episode.php             | 108 +++++--
 app/Controllers/Admin/PodcastImport.php       |   2 +-
 .../2020-05-30-101500_add_podcasts.php        |   2 +-
 .../2020-06-05-170000_add_episodes.php        |  26 +-
 .../2020-12-25-120000_add_persons.php         |   2 +-
 app/Entities/Episode.php                      | 220 +++++++-------
 app/Entities/Person.php                       |   8 +-
 app/Entities/Podcast.php                      |   8 +-
 app/Helpers/id3_helper.php                    |   6 +-
 app/Helpers/media_helper.php                  |  14 +-
 app/Helpers/rss_helper.php                    |  21 +-
 app/Language/en/Common.php                    |   2 +
 app/Language/en/Episode.php                   |  14 +-
 app/Language/en/Page.php                      |   2 +-
 app/Language/fr/Common.php                    |   2 +
 app/Language/fr/Episode.php                   |  16 +-
 app/Libraries/Analytics/Config/Analytics.php  |   6 +-
 .../EpisodeAnalyticsController.php            |   4 +-
 .../Analytics/Helpers/analytics_helper.php    |  38 +--
 app/Libraries/Image.php                       |  10 +-
 app/Models/EpisodeModel.php                   |  24 +-
 app/Models/PersonModel.php                    |   4 +-
 app/Models/PodcastModel.php                   |   4 +-
 app/Views/_assets/styles/formInputTabs.css    |  36 +++
 app/Views/_assets/styles/index.css            |   1 +
 app/Views/_assets/styles/tabs.css             |   4 +-
 app/Views/admin/episode/create.php            | 165 ++++++++--
 app/Views/admin/episode/edit.php              | 284 ++++++++++++------
 app/Views/admin/episode/list.php              |   8 +-
 app/Views/admin/episode/publish.php           |   8 +-
 app/Views/admin/episode/publish_edit.php      |   6 +-
 app/Views/admin/episode/soundbites.php        |  36 +--
 app/Views/admin/episode/view.php              |   2 +-
 app/Views/embeddable_player.php               |   4 +-
 app/Views/podcast/_partials/episode_card.php  |   8 +-
 app/Views/podcast/episode.php                 |  10 +-
 app/Views/podcast/episode_authenticated.php   |  10 +-
 app/Views/podcast/episodes.php                |   9 +-
 app/Views/podcast/episodes_authenticated.php  |   6 +-
 41 files changed, 752 insertions(+), 394 deletions(-)
 create mode 100644 app/Views/_assets/styles/formInputTabs.css

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 94f14a8101..6e1cc393fa 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,4 +1,4 @@
-image: php:7.2-fpm
+image: php:7.3-fpm
 
 stages:
   - bundle
diff --git a/app/Config/Analytics.php b/app/Config/Analytics.php
index 4391703d4d..c349725297 100644
--- a/app/Config/Analytics.php
+++ b/app/Config/Analytics.php
@@ -26,10 +26,10 @@ class Analytics extends AnalyticsBase
         $this->gateway = config('App')->adminGateway . '/analytics';
     }
 
-    public function getEnclosureUrl($enclosureUri)
+    public function getAudioFileUrl($audioFilePath)
     {
         helper('media');
 
-        return media_base_url($enclosureUri);
+        return media_base_url($audioFilePath);
     }
 }
diff --git a/app/Controllers/Admin/Episode.php b/app/Controllers/Admin/Episode.php
index 01c8412f80..619f420ef0 100644
--- a/app/Controllers/Admin/Episode.php
+++ b/app/Controllers/Admin/Episode.php
@@ -108,11 +108,12 @@ class Episode extends BaseController
     public function attemptCreate()
     {
         $rules = [
-            'enclosure' => 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]',
+            'audio_file' => 'uploaded[audio_file]|ext_in[audio_file,mp3,m4a]',
             'image' =>
                 'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
-            'transcript' => 'ext_in[transcript,txt,html,srt,json]|permit_empty',
-            'chapters' => 'ext_in[chapters,json]|permit_empty',
+            'transcript_file' =>
+                'ext_in[transcript,txt,html,srt,json]|permit_empty',
+            'chapters_file' => 'ext_in[chapters,json]|permit_empty',
         ];
 
         if (!$this->validate($rules)) {
@@ -127,7 +128,7 @@ class Episode extends BaseController
             'title' => $this->request->getPost('title'),
             'slug' => $this->request->getPost('slug'),
             'guid' => '',
-            'enclosure' => $this->request->getFile('enclosure'),
+            'audio_file' => $this->request->getFile('audio_file'),
             'description_markdown' => $this->request->getPost('description'),
             'image' => $this->request->getFile('image'),
             'location' => $this->request->getPost('location_name'),
@@ -151,6 +152,30 @@ class Episode extends BaseController
             'published_at' => null,
         ]);
 
+        $transcriptChoice = $this->request->getPost('transcript-choice');
+        if (
+            $transcriptChoice === 'upload-file' &&
+            ($transcriptFile = $this->request->getFile('transcript_file'))
+        ) {
+            $newEpisode->transcript_file = $transcriptFile;
+        } elseif ($transcriptChoice === 'remote-url') {
+            $newEpisode->transcript_file_remote_url = $this->request->getPost(
+                'transcript_file_remote_url',
+            );
+        }
+
+        $chaptersChoice = $this->request->getPost('chapters-choice');
+        if (
+            $chaptersChoice === 'upload-file' &&
+            ($chaptersFile = $this->request->getFile('chapters_file'))
+        ) {
+            $newEpisode->chapters_file = $chaptersFile;
+        } elseif ($chaptersChoice === 'remote-url') {
+            $newEpisode->chapters_file_remote_url = $this->request->getPost(
+                'chapters_file_remote_url',
+            );
+        }
+
         $episodeModel = new EpisodeModel();
 
         if (!($newEpisodeId = $episodeModel->insert($newEpisode, true))) {
@@ -201,12 +226,13 @@ class Episode extends BaseController
     public function attemptEdit()
     {
         $rules = [
-            'enclosure' =>
-                'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]|permit_empty',
+            'audio_file' =>
+                'uploaded[audio_file]|ext_in[audio_file,mp3,m4a]|permit_empty',
             'image' =>
                 'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
-            'transcript' => 'ext_in[transcript,txt,html,srt,json]|permit_empty',
-            'chapters' => 'ext_in[chapters,json]|permit_empty',
+            'transcript_file' =>
+                'ext_in[transcript_file,txt,html,srt,json]|permit_empty',
+            'chapters_file' => 'ext_in[chapters_file,json]|permit_empty',
         ];
 
         if (!$this->validate($rules)) {
@@ -240,21 +266,61 @@ class Episode extends BaseController
 
         $this->episode->updated_by = user()->id;
 
-        $enclosure = $this->request->getFile('enclosure');
-        if ($enclosure->isValid()) {
-            $this->episode->enclosure = $enclosure;
+        $audioFile = $this->request->getFile('audio_file');
+        if ($audioFile) {
+            $this->episode->audio_file = $audioFile;
         }
         $image = $this->request->getFile('image');
         if ($image) {
             $this->episode->image = $image;
         }
-        $transcript = $this->request->getFile('transcript');
-        if ($transcript->isValid()) {
-            $this->episode->transcript = $transcript;
+
+        $transcriptChoice = $this->request->getPost('transcript-choice');
+        if ($transcriptChoice === 'upload-file') {
+            $transcriptFile = $this->request->getFile('transcript_file');
+            if ($transcriptFile->isValid()) {
+                $this->episode->transcript_file = $transcriptFile;
+                $this->episode->transcript_file_remote_url = null;
+            }
+        } elseif ($transcriptChoice === 'remote-url') {
+            if (
+                $transcriptFileRemoteUrl = $this->request->getPost(
+                    'transcript_file_remote_url',
+                )
+            ) {
+                if (
+                    ($transcriptFile = $this->episode->transcript_file) &&
+                    !empty($transcriptFile)
+                ) {
+                    unlink($transcriptFile);
+                    $this->episode->transcript_file_path = null;
+                }
+            }
+            $this->episode->transcript_file_remote_url = $transcriptFileRemoteUrl;
         }
-        $chapters = $this->request->getFile('chapters');
-        if ($chapters->isValid()) {
-            $this->episode->chapters = $chapters;
+
+        $chaptersChoice = $this->request->getPost('chapters-choice');
+        if ($chaptersChoice === 'upload-file') {
+            $chaptersFile = $this->request->getFile('chapters_file');
+            if ($chaptersFile->isValid()) {
+                $this->episode->chapters_file = $chaptersFile;
+                $this->episode->chapters_file_remote_url = null;
+            }
+        } elseif ($chaptersChoice === 'remote-url') {
+            if (
+                $chaptersFileRemoteUrl = $this->request->getPost(
+                    'chapters_file_remote_url',
+                )
+            ) {
+                if (
+                    ($chaptersFile = $this->episode->chapters_file) &&
+                    !empty($chaptersFile)
+                ) {
+                    unlink($chaptersFile);
+                    $this->episode->chapters_file_path = null;
+                }
+            }
+            $this->episode->chapters_file_remote_url = $chaptersFileRemoteUrl;
         }
 
         $episodeModel = new EpisodeModel();
@@ -289,8 +355,8 @@ class Episode extends BaseController
 
     public function transcriptDelete()
     {
-        unlink($this->episode->transcript);
-        $this->episode->transcript_uri = null;
+        unlink($this->episode->transcript_file);
+        $this->episode->transcript_file_path = null;
 
         $episodeModel = new EpisodeModel();
 
@@ -306,8 +372,8 @@ class Episode extends BaseController
 
     public function chaptersDelete()
     {
-        unlink($this->episode->chapters);
-        $this->episode->chapters_uri = null;
+        unlink($this->episode->chapters_file);
+        $this->episode->chapters_file_path = null;
 
         $episodeModel = new EpisodeModel();
 
diff --git a/app/Controllers/Admin/PodcastImport.php b/app/Controllers/Admin/PodcastImport.php
index daad6c4234..6a29326156 100644
--- a/app/Controllers/Admin/PodcastImport.php
+++ b/app/Controllers/Admin/PodcastImport.php
@@ -345,7 +345,7 @@ class PodcastImport extends BaseController
                 'guid' => empty($item->guid) ? null : $item->guid,
                 'title' => $item->title,
                 'slug' => $slug,
-                'enclosure' => download_file($item->enclosure->attributes()),
+                'audio_file' => download_file($item->enclosure->attributes()),
                 'description_markdown' => $converter->convert(
                     $itemDescriptionHtml,
                 ),
diff --git a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
index d052c278af..675fa83c5c 100644
--- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
+++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
@@ -41,7 +41,7 @@ class AddPodcasts extends Migration
             'description_html' => [
                 'type' => 'TEXT',
             ],
-            'image_uri' => [
+            'image_path' => [
                 'type' => 'VARCHAR',
                 'constraint' => 255,
             ],
diff --git a/app/Database/Migrations/2020-06-05-170000_add_episodes.php b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
index 775e64c12d..cafca23779 100644
--- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php
+++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
@@ -39,25 +39,25 @@ class AddEpisodes extends Migration
                 'type' => 'VARCHAR',
                 'constraint' => 191,
             ],
-            'enclosure_uri' => [
+            'audio_file_path' => [
                 'type' => 'VARCHAR',
                 'constraint' => 255,
             ],
-            'enclosure_duration' => [
+            'audio_file_duration' => [
                 'type' => 'INT',
                 'unsigned' => true,
                 'comment' => 'Playtime in seconds',
             ],
-            'enclosure_mimetype' => [
+            'audio_file_mimetype' => [
                 'type' => 'VARCHAR',
                 'constraint' => 255,
             ],
-            'enclosure_filesize' => [
+            'audio_file_size' => [
                 'type' => 'INT',
                 'unsigned' => true,
                 'comment' => 'File size in bytes',
             ],
-            'enclosure_headersize' => [
+            'audio_file_header_size' => [
                 'type' => 'INT',
                 'unsigned' => true,
                 'comment' => 'Header size in bytes',
@@ -68,7 +68,7 @@ class AddEpisodes extends Migration
             'description_html' => [
                 'type' => 'TEXT',
             ],
-            'image_uri' => [
+            'image_path' => [
                 'type' => 'VARCHAR',
                 'constraint' => 255,
                 'null' => true,
@@ -80,16 +80,26 @@ class AddEpisodes extends Migration
                 'constraint' => 13,
                 'null' => true,
             ],
-            'transcript_uri' => [
+            'transcript_file_path' => [
                 'type' => 'VARCHAR',
                 'constraint' => 255,
                 'null' => true,
             ],
-            'chapters_uri' => [
+            'transcript_file_remote_url' => [
+                'type' => 'VARCHAR',
+                'constraint' => 512,
+                'null' => true,
+            ],
+            'chapters_file_path' => [
                 'type' => 'VARCHAR',
                 'constraint' => 255,
                 'null' => true,
             ],
+            'chapters_file_remote_url' => [
+                'type' => 'VARCHAR',
+                'constraint' => 512,
+                'null' => true,
+            ],
             'parental_advisory' => [
                 'type' => 'ENUM',
                 'constraint' => ['clean', 'explicit'],
diff --git a/app/Database/Migrations/2020-12-25-120000_add_persons.php b/app/Database/Migrations/2020-12-25-120000_add_persons.php
index f993360479..84b34ccf9a 100644
--- a/app/Database/Migrations/2020-12-25-120000_add_persons.php
+++ b/app/Database/Migrations/2020-12-25-120000_add_persons.php
@@ -41,7 +41,7 @@ class AddPersons extends Migration
                     'The url to a relevant resource of information about the person, such as a homepage or third-party profile platform.',
                 'null' => true,
             ],
-            'image_uri' => [
+            'image_path' => [
                 'type' => 'VARCHAR',
                 'constraint' => 255,
             ],
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index d0cdf64296..338e987989 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -13,6 +13,8 @@ use App\Models\SoundbiteModel;
 use App\Models\EpisodePersonModel;
 use App\Models\NoteModel;
 use CodeIgniter\Entity;
+use CodeIgniter\Files\Exceptions\FileNotFoundException;
+use CodeIgniter\HTTP\Exceptions\HTTPException;
 use CodeIgniter\I18n\Time;
 use League\CommonMark\CommonMarkConverter;
 
@@ -36,47 +38,37 @@ class Episode extends Entity
     /**
      * @var \CodeIgniter\Files\File
      */
-    protected $enclosure;
+    protected $audioFile;
 
     /**
      * @var \CodeIgniter\Files\File
      */
-    protected $transcript;
+    protected $transcript_file;
 
     /**
      * @var \CodeIgniter\Files\File
      */
-    protected $chapters;
+    protected $chapters_file;
 
     /**
      * @var string
      */
-    protected $enclosure_media_path;
+    protected $audio_file_url;
 
     /**
      * @var string
      */
-    protected $enclosure_url;
+    protected $audio_file_analytics_url;
 
     /**
      * @var string
      */
-    protected $enclosure_web_url;
+    protected $audio_file_web_url;
 
     /**
      * @var string
      */
-    protected $enclosure_opengraph_url;
-
-    /**
-     * @var string
-     */
-    protected $transcript_url;
-
-    /**
-     * @var string
-     */
-    protected $chapters_url;
+    protected $audio_file_opengraph_url;
 
     /**
      * @var \App\Entities\EpisodePerson[]
@@ -132,17 +124,19 @@ class Episode extends Entity
         'guid' => 'string',
         'slug' => 'string',
         'title' => 'string',
-        'enclosure_uri' => 'string',
-        'enclosure_duration' => 'integer',
-        'enclosure_mimetype' => 'string',
-        'enclosure_filesize' => 'integer',
-        'enclosure_headersize' => 'integer',
+        'audio_file_path' => 'string',
+        'audio_file_duration' => 'integer',
+        'audio_file_mimetype' => 'string',
+        'audio_file_size' => 'integer',
+        'audio_file_header_size' => 'integer',
         'description_markdown' => 'string',
         'description_html' => 'string',
-        'image_uri' => '?string',
+        'image_path' => '?string',
         'image_mimetype' => '?string',
-        'transcript_uri' => '?string',
-        'chapters_uri' => '?string',
+        'transcript_file_path' => '?string',
+        'transcript_file_remote_url' => '?string',
+        'chapters_file_path' => '?string',
+        'chapters_file_remote_url' => '?string',
         'parental_advisory' => '?string',
         'number' => '?integer',
         'season_number' => '?integer',
@@ -176,13 +170,13 @@ class Episode extends Entity
 
             // check whether the user has inputted an image and store
             $this->attributes['image_mimetype'] = $image->getMimeType();
-            $this->attributes['image_uri'] = save_media(
+            $this->attributes['image_path'] = save_media(
                 $image,
                 'podcasts/' . $this->getPodcast()->name,
                 $this->attributes['slug'],
             );
             $this->image = new \App\Libraries\Image(
-                $this->attributes['image_uri'],
+                $this->attributes['image_path'],
                 $this->attributes['image_mimetype'],
             );
             $this->image->saveSizes();
@@ -193,9 +187,9 @@ class Episode extends Entity
 
     public function getImage(): \App\Libraries\Image
     {
-        if ($image_uri = $this->attributes['image_uri']) {
+        if ($imagePath = $this->attributes['image_path']) {
             return new \App\Libraries\Image(
-                $image_uri,
+                $imagePath,
                 $this->attributes['image_mimetype'],
             );
         }
@@ -203,58 +197,59 @@ class Episode extends Entity
     }
 
     /**
-     * Saves an enclosure
+     * Saves an audio file
      *
-     * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $enclosure
+     * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $audioFile
      *
      */
-    public function setEnclosure($enclosure = null)
+    public function setAudioFile($audioFile = null)
     {
         if (
-            !empty($enclosure) &&
-            (!($enclosure instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
-                $enclosure->isValid())
+            !empty($audioFile) &&
+            (!($audioFile instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
+                $audioFile->isValid())
         ) {
             helper(['media', 'id3']);
 
-            $enclosure_metadata = get_file_tags($enclosure);
+            $audio_metadata = get_file_tags($audioFile);
 
-            $this->attributes['enclosure_uri'] = save_media(
-                $enclosure,
+            $this->attributes['audio_file_path'] = save_media(
+                $audioFile,
                 'podcasts/' . $this->getPodcast()->name,
                 $this->attributes['slug'],
             );
-            $this->attributes['enclosure_duration'] = round(
-                $enclosure_metadata['playtime_seconds'],
+            $this->attributes['audio_file_duration'] = round(
+                $audio_metadata['playtime_seconds'],
             );
-            $this->attributes['enclosure_mimetype'] =
-                $enclosure_metadata['mime_type'];
-            $this->attributes['enclosure_filesize'] =
-                $enclosure_metadata['filesize'];
-            $this->attributes['enclosure_headersize'] =
-                $enclosure_metadata['avdataoffset'];
+            $this->attributes['audio_file_mimetype'] =
+                $audio_metadata['mime_type'];
+            $this->attributes['audio_file_size'] = $audio_metadata['filesize'];
+            $this->attributes['audio_file_header_size'] =
+                $audio_metadata['avdataoffset'];
 
             return $this;
         }
     }
 
     /**
-     * Saves an episode transcript
+     * Saves an episode transcript file
      *
-     * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $transcript
+     * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $transcriptFile
      *
      */
-    public function setTranscript($transcript)
+    public function setTranscriptFile($transcriptFile)
     {
         if (
-            !empty($transcript) &&
-            (!($transcript instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
-                $transcript->isValid())
+            !empty($transcriptFile) &&
+            (!(
+                $transcriptFile instanceof \CodeIgniter\HTTP\Files\UploadedFile
+            ) ||
+                $transcriptFile->isValid())
         ) {
             helper('media');
 
-            $this->attributes['transcript_uri'] = save_media(
-                $transcript,
+            $this->attributes['transcript_file_path'] = save_media(
+                $transcriptFile,
                 $this->getPodcast()->name,
                 $this->attributes['slug'] . '-transcript',
             );
@@ -264,22 +259,22 @@ class Episode extends Entity
     }
 
     /**
-     * Saves an episode chapters
+     * Saves an episode chapters file
      *
-     * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $chapters
+     * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $chaptersFile
      *
      */
-    public function setChapters($chapters)
+    public function setChaptersFile($chaptersFile)
     {
         if (
-            !empty($chapters) &&
-            (!($chapters instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
-                $chapters->isValid())
+            !empty($chaptersFile) &&
+            (!($chaptersFile instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
+                $chaptersFile->isValid())
         ) {
             helper('media');
 
-            $this->attributes['chapters_uri'] = save_media(
-                $chapters,
+            $this->attributes['chapters_file_path'] = save_media(
+                $chaptersFile,
                 $this->getPodcast()->name,
                 $this->attributes['slug'] . '-chapters',
             );
@@ -288,87 +283,102 @@ class Episode extends Entity
         return $this;
     }
 
-    public function getEnclosure()
+    public function getAudioFile()
     {
-        return new \CodeIgniter\Files\File($this->getEnclosureMediaPath());
-    }
+        helper('media');
 
-    public function getTranscript()
-    {
-        return $this->attributes['transcript_uri']
-            ? new \CodeIgniter\Files\File($this->getTranscriptMediaPath())
-            : null;
+        return new \CodeIgniter\Files\File(media_path($this->audio_file_path));
     }
 
-    public function getChapters()
+    public function getTranscriptFile()
     {
-        return $this->attributes['chapters_uri']
-            ? new \CodeIgniter\Files\File($this->getChaptersMediaPath())
-            : null;
-    }
+        if ($this->attributes['transcript_file_path']) {
+            helper('media');
 
-    public function getEnclosureMediaPath()
-    {
-        helper('media');
+            return new \CodeIgniter\Files\File(
+                media_path($this->attributes['transcript_file_path']),
+            );
+        }
 
-        return media_path($this->attributes['enclosure_uri']);
+        return null;
     }
 
-    public function getTranscriptMediaPath()
+    public function getChaptersFile()
     {
-        helper('media');
+        if ($this->attributes['chapters_file_path']) {
+            helper('media');
+
+            return new \CodeIgniter\Files\File(
+                media_path($this->attributes['chapters_file_path']),
+            );
+        }
 
-        return $this->attributes['transcript_uri']
-            ? media_path($this->attributes['transcript_uri'])
-            : null;
+        return null;
     }
 
-    public function getChaptersMediaPath()
+    public function getAudioFileUrl()
     {
         helper('media');
 
-        return $this->attributes['chapters_uri']
-            ? media_path($this->attributes['chapters_uri'])
-            : null;
+        return media_url($this->audio_file_path);
     }
 
-    public function getEnclosureUrl()
+    public function getAudioFileAnalyticsUrl()
     {
         helper('analytics');
 
         return generate_episode_analytics_url(
             $this->podcast_id,
             $this->id,
-            $this->enclosure_uri,
-            $this->enclosure_duration,
-            $this->enclosure_filesize,
-            $this->enclosure_headersize,
+            $this->audio_file_path,
+            $this->audio_file_duration,
+            $this->audio_file_size,
+            $this->audio_file_header_size,
             $this->published_at,
         );
     }
 
-    public function getEnclosureWebUrl()
+    public function getAudioFileWebUrl()
     {
-        return $this->getEnclosureUrl() . '?_from=-+Website+-';
+        return $this->getAudioFileAnalyticsUrl() . '?_from=-+Website+-';
     }
 
-    public function getEnclosureOpengraphUrl()
+    public function getAudioFileOpengraphUrl()
     {
-        return $this->getEnclosureUrl() . '?_from=-+Open+Graph+-';
+        return $this->getAudioFileAnalyticsUrl() . '?_from=-+Open+Graph+-';
     }
 
-    public function getTranscriptUrl()
+    /**
+     * Gets transcript url from transcript file uri if it exists
+     * or returns the transcript_file_remote_url which can be null.
+     *
+     * @return string|null
+     * @throws FileNotFoundException
+     * @throws HTTPException
+     */
+    public function getTranscriptFileUrl()
     {
-        return $this->attributes['transcript_uri']
-            ? base_url($this->getTranscriptMediaPath())
-            : null;
+        if ($this->attributes['transcript_file_path']) {
+            return media_url($this->attributes['transcript_file_path']);
+        } else {
+            return $this->attributes['transcript_file_remote_url'];
+        }
     }
 
-    public function getChaptersUrl()
+    /**
+     * Gets chapters file url from chapters file uri if it exists
+     * or returns the chapters_file_remote_url which can be null.
+     *
+     * @return mixed
+     * @throws HTTPException
+     */
+    public function getChaptersFileUrl()
     {
-        return $this->attributes['chapters_uri']
-            ? base_url($this->getChaptersMediaPath())
-            : null;
+        if ($this->attributes['chapters_file_path']) {
+            return media_url($this->attributes['chapters_file_path']);
+        } else {
+            return $this->attributes['chapters_file_remote_url'];
+        }
     }
 
     /**
diff --git a/app/Entities/Person.php b/app/Entities/Person.php
index 222aa56fbb..d6c9c50fdd 100644
--- a/app/Entities/Person.php
+++ b/app/Entities/Person.php
@@ -22,7 +22,7 @@ class Person extends Entity
         'full_name' => 'string',
         'unique_name' => 'string',
         'information_url' => '?string',
-        'image_uri' => 'string',
+        'image_path' => 'string',
         'image_mimetype' => 'string',
         'created_by' => 'integer',
         'updated_by' => 'integer',
@@ -40,13 +40,13 @@ class Person extends Entity
             helper('media');
 
             $this->attributes['image_mimetype'] = $image->getMimeType();
-            $this->attributes['image_uri'] = save_media(
+            $this->attributes['image_path'] = save_media(
                 $image,
                 'persons',
                 $this->attributes['unique_name'],
             );
             $this->image = new \App\Libraries\Image(
-                $this->attributes['image_uri'],
+                $this->attributes['image_path'],
                 $this->attributes['image_mimetype'],
             );
             $this->image->saveSizes();
@@ -58,7 +58,7 @@ class Person extends Entity
     public function getImage()
     {
         return new \App\Libraries\Image(
-            $this->attributes['image_uri'],
+            $this->attributes['image_path'],
             $this->attributes['image_mimetype'],
         );
     }
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index db7af33717..740a0134ad 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -100,7 +100,7 @@ class Podcast extends Entity
         'title' => 'string',
         'description_markdown' => 'string',
         'description_html' => 'string',
-        'image_uri' => 'string',
+        'image_path' => 'string',
         'image_mimetype' => 'string',
         'language_code' => 'string',
         'category_id' => 'integer',
@@ -161,14 +161,14 @@ class Podcast extends Entity
             helper('media');
 
             $this->attributes['image_mimetype'] = $image->getMimeType();
-            $this->attributes['image_uri'] = save_media(
+            $this->attributes['image_path'] = save_media(
                 $image,
                 'podcasts/' . $this->attributes['name'],
                 'cover',
             );
 
             $this->image = new \App\Libraries\Image(
-                $this->attributes['image_uri'],
+                $this->attributes['image_path'],
                 $this->attributes['image_mimetype'],
             );
             $this->image->saveSizes();
@@ -180,7 +180,7 @@ class Podcast extends Entity
     public function getImage()
     {
         return new \App\Libraries\Image(
-            $this->attributes['image_uri'],
+            $this->attributes['image_path'],
             $this->attributes['image_mimetype'],
         );
     }
diff --git a/app/Helpers/id3_helper.php b/app/Helpers/id3_helper.php
index 5046c72c46..ab0a567cf7 100644
--- a/app/Helpers/id3_helper.php
+++ b/app/Helpers/id3_helper.php
@@ -36,13 +36,15 @@ function get_file_tags($file)
  *
  * @return UploadedFile
  */
-function write_enclosure_tags($episode)
+function write_audio_file_tags($episode)
 {
+    helper('media');
+
     $TextEncoding = 'UTF-8';
 
     // Initialize getID3 tag-writing module
     $tagwriter = new WriteTags();
-    $tagwriter->filename = $episode->enclosure_media_path;
+    $tagwriter->filename = media_path($episode->audio_file_path);
 
     // set various options (optional)
     $tagwriter->tagformats = ['id3v2.4'];
diff --git a/app/Helpers/media_helper.php b/app/Helpers/media_helper.php
index a7e2eb6926..cd71e3e608 100644
--- a/app/Helpers/media_helper.php
+++ b/app/Helpers/media_helper.php
@@ -12,15 +12,15 @@ use CodeIgniter\HTTP\ResponseInterface;
 /**
  * Saves a file to the corresponding podcast folder in `public/media`
  *
- * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $file
- * @param string $podcast_name
- * @param string $file_name
+ * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $filePath
+ * @param string $folder
+ * @param string $fileName
  *
  * @return string The episode's file path in media root
  */
-function save_media($file, $folder, $mediaName)
+function save_media($filePath, $folder, $mediaName)
 {
-    $file_name = $mediaName . '.' . $file->getExtension();
+    $fileName = $mediaName . '.' . $filePath->getExtension();
 
     $mediaRoot = config('App')->mediaRoot . '/' . $folder;
 
@@ -30,9 +30,9 @@ function save_media($file, $folder, $mediaName)
     }
 
     // move to media folder and overwrite file if already existing
-    $file->move($mediaRoot . '/', $file_name, true);
+    $filePath->move($mediaRoot . '/', $fileName, true);
 
-    return $folder . '/' . $file_name;
+    return $folder . '/' . $fileName;
 }
 
 /**
diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php
index 112b4648a8..a49562cc98 100644
--- a/app/Helpers/rss_helper.php
+++ b/app/Helpers/rss_helper.php
@@ -258,13 +258,13 @@ function get_rss_feed($podcast, $serviceSlug = '')
 
         $enclosure->addAttribute(
             'url',
-            $episode->enclosure_url .
+            $episode->audio_file_analytics_url .
                 (empty($serviceSlug)
                     ? ''
                     : '?_from=' . urlencode($serviceSlug)),
         );
-        $enclosure->addAttribute('length', $episode->enclosure_filesize);
-        $enclosure->addAttribute('type', $episode->enclosure_mimetype);
+        $enclosure->addAttribute('length', $episode->audio_file_size);
+        $enclosure->addAttribute('type', $episode->audio_file_mimetype);
 
         $item->addChild('guid', $episode->guid);
         $item->addChild(
@@ -290,7 +290,7 @@ function get_rss_feed($podcast, $serviceSlug = '')
         );
         $item->addChild(
             'duration',
-            $episode->enclosure_duration,
+            $episode->audio_file_duration,
             $itunes_namespace,
         );
         $item->addChild('link', $episode->link);
@@ -318,17 +318,20 @@ function get_rss_feed($podcast, $serviceSlug = '')
             );
         $item->addChild('episodeType', $episode->type, $itunes_namespace);
 
-        if ($episode->transcript) {
+        if ($episode->transcript_file_url) {
             $transcriptElement = $item->addChild(
                 'transcript',
                 null,
                 $podcast_namespace,
             );
-            $transcriptElement->addAttribute('url', $episode->transcriptUrl);
+            $transcriptElement->addAttribute(
+                'url',
+                $episode->transcript_file_url,
+            );
             $transcriptElement->addAttribute(
                 'type',
                 Mimes::guessTypeFromExtension(
-                    pathinfo($episode->transcript_uri, PATHINFO_EXTENSION),
+                    pathinfo($episode->transcript_file_url, PATHINFO_EXTENSION),
                 ),
             );
             $transcriptElement->addAttribute(
@@ -337,13 +340,13 @@ function get_rss_feed($podcast, $serviceSlug = '')
             );
         }
 
-        if ($episode->chapters) {
+        if ($episode->chapters_file_url) {
             $chaptersElement = $item->addChild(
                 'chapters',
                 null,
                 $podcast_namespace,
             );
-            $chaptersElement->addAttribute('url', $episode->chaptersUrl);
+            $chaptersElement->addAttribute('url', $episode->chapters_file_url);
             $chaptersElement->addAttribute('type', 'application/json+chapters');
         }
 
diff --git a/app/Language/en/Common.php b/app/Language/en/Common.php
index d2a5966c16..da350b4322 100644
--- a/app/Language/en/Common.php
+++ b/app/Language/en/Common.php
@@ -31,5 +31,7 @@ return [
         ],
         'image_size_hint' =>
             'Image must be squared with at least 1400px wide and tall.',
+        'upload_file' => 'Upload a file',
+        'remote_url' => 'Remote URL',
     ],
 ];
diff --git a/app/Language/en/Episode.php b/app/Language/en/Episode.php
index 83210875ed..5eaa06caae 100644
--- a/app/Language/en/Episode.php
+++ b/app/Language/en/Episode.php
@@ -45,8 +45,8 @@ return [
     'form' => [
         'warning' =>
             'In case of fatal error, try increasing the `memory_limit`, `upload_max_filesize` and `post_max_size` values in your php configuration file then restart your web server.<br />These values must be higher than the audio file you wish to upload.',
-        'enclosure' => 'Audio file',
-        'enclosure_hint' => 'Choose an .mp3 or .m4a audio file.',
+        'audio_file' => 'Audio file',
+        'audio_file_hint' => 'Choose an .mp3 or .m4a audio file.',
         'info_section_title' => 'Episode info',
         'info_section_subtitle' => '',
         'image' => 'Cover image',
@@ -90,10 +90,14 @@ return [
         'location_name_hint' => 'This can be a real or fictional location',
         'transcript' => 'Transcript or closed captions',
         'transcript_hint' => 'Allowed formats are txt, html, srt or json.',
-        'transcript_delete' => 'Delete transcript',
+        'transcript_file' => 'Transcript file',
+        'transcript_file_remote_url' => 'Remote url for transcript',
+        'transcript_file_delete' => 'Delete transcript file',
         'chapters' => 'Chapters',
-        'chapters_hint' => 'File should be in JSON Chapters Format.',
-        'chapters_delete' => 'Delete chapters',
+        'chapters_hint' => 'File must be in JSON Chapters format.',
+        'chapters_file' => 'Chapters file',
+        'chapters_file_remote_url' => 'Remote url for chapters file',
+        'chapters_file_delete' => 'Delete chapters file',
         'advanced_section_title' => 'Advanced Parameters',
         'advanced_section_subtitle' =>
             'If you need RSS tags that Castopod does not handle, set them here.',
diff --git a/app/Language/en/Page.php b/app/Language/en/Page.php
index 4ddf960222..cafd882084 100644
--- a/app/Language/en/Page.php
+++ b/app/Language/en/Page.php
@@ -22,6 +22,6 @@ return [
         'submit_edit' => 'Save',
     ],
     'messages' => [
-        'createSuccess' => 'The page "{pageTitle}" was created successfully!',
+        'createSuccess' => 'The page “{pageTitle}” was created successfully!',
     ],
 ];
diff --git a/app/Language/fr/Common.php b/app/Language/fr/Common.php
index 48b599e0fd..8d466bf28d 100644
--- a/app/Language/fr/Common.php
+++ b/app/Language/fr/Common.php
@@ -31,5 +31,7 @@ return [
         ],
         'image_size_hint' =>
             'L’image doit être carrée, avec au minimum 1400px de long et de large.',
+        'upload_file' => 'Téléversez un fichier',
+        'remote_url' => 'URL distante',
     ],
 ];
diff --git a/app/Language/fr/Episode.php b/app/Language/fr/Episode.php
index a2989f6797..df4a5d90b2 100644
--- a/app/Language/fr/Episode.php
+++ b/app/Language/fr/Episode.php
@@ -45,8 +45,8 @@ return [
     'form' => [
         'warning' =>
             'En cas d’erreur fatale, essayez d’augmenter les valeurs de `memory_limit`, `upload_max_filesize` et `post_max_size` dans votre fichier de configuration php puis redémarrez votre serveur web.<br />Les valeurs doivent être plus grandes que le fichier audio que vous souhaitez téléverser.',
-        'enclosure' => 'Fichier audio',
-        'enclosure_hint' => 'Sélectionnez un fichier audio .mp3 ou .m4a.',
+        'audio_file' => 'Fichier audio',
+        'audio_file_hint' => 'Sélectionnez un fichier audio .mp3 ou .m4a.',
         'info_section_title' => 'Informations épisode',
         'info_section_subtitle' => '',
         'image' => 'Image de couverture',
@@ -91,10 +91,16 @@ return [
         'transcript' => 'Transcription ou sous-titrage',
         'transcript_hint' =>
             'Les formats autorisés sont txt, html, srt ou json.',
-        'transcript_delete' => 'Supprimer la transcription',
+        'transcript_file' => 'Fichier de transcription',
+        'transcript_file_remote_url' =>
+            'URL distante pour le fichier de transcription',
+        'transcript_file_delete' => 'Supprimer le fichier de transcription',
         'chapters' => 'Chapitrage',
-        'chapters_hint' => 'Le fichier doit être en "JSON Chapters Format".',
-        'chapters_delete' => 'Supprimer le chapitrage',
+        'chapters_hint' => 'Le fichier doit être en format “JSON Chapters”.',
+        'chapters_file' => 'Fichier de chapitrage',
+        'chapters_file_remote_url' =>
+            'URL distante pour le fichier de chapitrage',
+        'chapters_file_delete' => 'Supprimer le fichier de chapitrage',
         'advanced_section_title' => 'Paramètres avancés',
         'advanced_section_subtitle' =>
             'Si vous avez besoin d’une balise que Castopod ne couvre pas, définissez-la ici.',
diff --git a/app/Libraries/Analytics/Config/Analytics.php b/app/Libraries/Analytics/Config/Analytics.php
index fa5ff6a4f5..64a85b5d25 100644
--- a/app/Libraries/Analytics/Config/Analytics.php
+++ b/app/Libraries/Analytics/Config/Analytics.php
@@ -26,13 +26,13 @@ class Analytics extends BaseConfig
     ];
 
     /**
-     * get the full enclosure url
+     * get the full audio file url
      *
      * @param string $filename
      * @return string
      */
-    public function getEnclosureUrl(string $enclosureUri)
+    public function getAudioFileUrl(string $audioFilePath)
     {
-        return base_url($enclosureUri);
+        return base_url($audioFilePath);
     }
 }
diff --git a/app/Libraries/Analytics/Controllers/EpisodeAnalyticsController.php b/app/Libraries/Analytics/Controllers/EpisodeAnalyticsController.php
index 7e372d9ae9..b9a2cb4376 100644
--- a/app/Libraries/Analytics/Controllers/EpisodeAnalyticsController.php
+++ b/app/Libraries/Analytics/Controllers/EpisodeAnalyticsController.php
@@ -50,7 +50,7 @@ class EpisodeAnalyticsController extends Controller
     }
 
     // Add one hit to this episode:
-    public function hit($base64EpisodeData, ...$enclosureUri)
+    public function hit($base64EpisodeData, ...$audioFilePath)
     {
         $session = \Config\Services::session();
         $session->start();
@@ -78,6 +78,6 @@ class EpisodeAnalyticsController extends Controller
             $serviceName,
         );
 
-        return redirect()->to($this->config->getEnclosureUrl($enclosureUri));
+        return redirect()->to($this->config->getAudioFileUrl($audioFilePath));
     }
 }
diff --git a/app/Libraries/Analytics/Helpers/analytics_helper.php b/app/Libraries/Analytics/Helpers/analytics_helper.php
index a9fc7c5791..e18806a8af 100644
--- a/app/Libraries/Analytics/Helpers/analytics_helper.php
+++ b/app/Libraries/Analytics/Helpers/analytics_helper.php
@@ -30,15 +30,15 @@ if (!function_exists('base64_url_decode')) {
 
 if (!function_exists('generate_episode_analytics_url')) {
     /**
-     * Builds the episode analytics url that redirects to the enclosure url
+     * Builds the episode analytics url that redirects to the audio file url
      * after analytics hit.
      *
      * @param int $podcastId
      * @param int $episodeId
-     * @param string $enclosureUri
-     * @param int $enclosureDuration
-     * @param int $enclosureFilesize
-     * @param int $enclosureHeadersize
+     * @param string $audioFilePath
+     * @param int $audioFileDuration
+     * @param int $audioFileSize
+     * @param int $audioFileHeaderSize
      * @param \CodeIgniter\I18n\Time $publicationDate
      *
      * @return string
@@ -47,10 +47,10 @@ if (!function_exists('generate_episode_analytics_url')) {
     function generate_episode_analytics_url(
         $podcastId,
         $episodeId,
-        $enclosureUri,
-        $enclosureDuration,
-        $enclosureFilesize,
-        $enclosureHeadersize,
+        $audioFilePath,
+        $audioFileDuration,
+        $audioFileFilesize,
+        $audioFileHeaderSize,
         $publicationDate
     ) {
         return url_to(
@@ -61,22 +61,22 @@ if (!function_exists('generate_episode_analytics_url')) {
                     $podcastId,
                     $episodeId,
                     // bytes_threshold: number of bytes that must be downloaded for an episode to be counted in download analytics
-                    // - if file is shorter than 60sec, then it's enclosure_filesize
-                    // - if file is longer than 60 seconds then it's enclosure_headersize + 60 seconds
-                    $enclosureDuration <= 60
-                        ? $enclosureFilesize
-                        : $enclosureHeadersize +
+                    // - if file is shorter than 60sec, then it's audio_file_size
+                    // - if file is longer than 60 seconds then it's audio_file_header_size + 60 seconds
+                    $audioFileDuration <= 60
+                        ? $audioFileFilesize
+                        : $audioFileHeaderSize +
                             floor(
-                                (($enclosureFilesize - $enclosureHeadersize) /
-                                    $enclosureDuration) *
+                                (($audioFileFilesize - $audioFileHeaderSize) /
+                                    $audioFileDuration) *
                                     60,
                             ),
-                    $enclosureFilesize,
-                    $enclosureDuration,
+                    $audioFileFilesize,
+                    $audioFileDuration,
                     strtotime($publicationDate),
                 ),
             ),
-            $enclosureUri,
+            $audioFilePath,
         );
     }
 }
diff --git a/app/Libraries/Image.php b/app/Libraries/Image.php
index c713393709..78a5c2ac7e 100644
--- a/app/Libraries/Image.php
+++ b/app/Libraries/Image.php
@@ -70,17 +70,17 @@ class Image
      */
     public $id3_path;
 
-    public function __construct($originalUri, $mimetype)
+    public function __construct($originalPath, $mimetype)
     {
         helper('media');
 
-        $originalPath = media_path($originalUri);
+        $originalMediaPath = media_path($originalPath);
 
         [
             'filename' => $filename,
             'dirname' => $dirname,
             'extension' => $extension,
-        ] = pathinfo($originalPath);
+        ] = pathinfo($originalMediaPath);
 
         // load images extensions from config
         $this->config = config('Images');
@@ -100,8 +100,8 @@ class Image
         $feed = $dirname . '/' . $filename . $feedExtension . '.' . $extension;
         $id3 = $dirname . '/' . $filename . $id3Extension . '.' . $extension;
 
-        $this->original_path = $originalPath;
-        $this->original_url = media_url($originalUri);
+        $this->original_path = $originalMediaPath;
+        $this->original_url = media_url($originalMediaPath);
         $this->thumbnail_path = $thumbnail;
         $this->thumbnail_url = base_url($thumbnail);
         $this->medium_path = $medium;
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index 8d36325bb0..9679d717c5 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -21,17 +21,19 @@ class EpisodeModel extends Model
         'guid',
         'title',
         'slug',
-        'enclosure_uri',
-        'enclosure_duration',
-        'enclosure_mimetype',
-        'enclosure_filesize',
-        'enclosure_headersize',
+        'audio_file_path',
+        'audio_file_duration',
+        'audio_file_mimetype',
+        'audio_file_size',
+        'audio_file_header_size',
         'description_markdown',
         'description_html',
-        'image_uri',
+        'image_path',
         'image_mimetype',
-        'transcript_uri',
-        'chapters_uri',
+        'transcript_file_path',
+        'transcript_file_remote_url',
+        'chapters_file_path',
+        'chapters_file_remote_url',
         'parental_advisory',
         'number',
         'season_number',
@@ -58,11 +60,13 @@ class EpisodeModel extends Model
         'podcast_id' => 'required',
         'title' => 'required',
         'slug' => 'required|regex_match[/^[a-zA-Z0-9\-]{1,191}$/]',
-        'enclosure_uri' => 'required',
+        'audio_file_path' => 'required',
         'description_markdown' => 'required',
         'number' => 'is_natural_no_zero|permit_empty',
         'season_number' => 'is_natural_no_zero|permit_empty',
         'type' => 'required',
+        'transcript_file_remote_url' => 'valid_url|permit_empty',
+        'chapters_file_remote_url' => 'valid_url|permit_empty',
         'published_at' => 'valid_date|permit_empty',
         'created_by' => 'required',
         'updated_by' => 'required',
@@ -268,7 +272,7 @@ class EpisodeModel extends Model
             is_array($data['id']) ? $data['id'][0] : $data['id'],
         );
 
-        write_enclosure_tags($episode);
+        write_audio_file_tags($episode);
 
         return $data;
     }
diff --git a/app/Models/PersonModel.php b/app/Models/PersonModel.php
index fc19b3988d..41dee4f269 100644
--- a/app/Models/PersonModel.php
+++ b/app/Models/PersonModel.php
@@ -20,7 +20,7 @@ class PersonModel extends Model
         'full_name',
         'unique_name',
         'information_url',
-        'image_uri',
+        'image_path',
         'image_mimetype',
         'created_by',
         'updated_by',
@@ -35,7 +35,7 @@ class PersonModel extends Model
         'full_name' => 'required',
         'unique_name' =>
             'required|regex_match[/^[a-z0-9\-]{1,191}$/]|is_unique[persons.unique_name,id,{id}]',
-        'image_uri' => 'required',
+        'image_path' => 'required',
         'created_by' => 'required',
         'updated_by' => 'required',
     ];
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index a332f763c5..f8d79e00ab 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -25,7 +25,7 @@ class PodcastModel extends Model
         'description_html',
         'episode_description_footer_markdown',
         'episode_description_footer_html',
-        'image_uri',
+        'image_path',
         'image_mimetype',
         'language_code',
         'category_id',
@@ -62,7 +62,7 @@ class PodcastModel extends Model
         'name' =>
             'required|regex_match[/^[a-zA-Z0-9\_]{1,191}$/]|is_unique[podcasts.name,id,{id}]',
         'description_markdown' => 'required',
-        'image_uri' => 'required',
+        'image_path' => 'required',
         'language_code' => 'required',
         'category_id' => 'required',
         'owner_email' => 'required|valid_email',
diff --git a/app/Views/_assets/styles/formInputTabs.css b/app/Views/_assets/styles/formInputTabs.css
new file mode 100644
index 0000000000..011d39a96e
--- /dev/null
+++ b/app/Views/_assets/styles/formInputTabs.css
@@ -0,0 +1,36 @@
+@layer components {
+  .form-input-tabs > input[type="radio"] {
+    @apply absolute -left-full;
+  }
+
+  .form-input-tabs .tab-panel {
+    @apply hidden;
+  }
+
+  /* Logic for 2 tabs at most */
+  .form-input-tabs
+    > input:first-child:checked
+    ~ .tab-panels
+    > .tab-panel:first-child,
+  .form-input-tabs
+    > input:nth-child(3):checked
+    ~ .tab-panels
+    > .tab-panel:nth-child(2) {
+    @apply block;
+  }
+
+  /* Styling */
+  .form-input-tabs > label {
+    @apply relative inline-block px-1 py-2 text-xs text-center cursor-pointer opacity-70 hover:opacity-100;
+  }
+
+  .form-input-tabs > input:checked + label::after {
+    @apply absolute inset-x-0 bottom-0 w-full mx-auto bg-pine-700;
+    content: "";
+    height: 0.2rem;
+  }
+
+  .form-input-tabs > input:checked + label {
+    @apply font-semibold opacity-100 text-pine-700;
+  }
+}
diff --git a/app/Views/_assets/styles/index.css b/app/Views/_assets/styles/index.css
index eb98956f1e..b4b795a546 100644
--- a/app/Views/_assets/styles/index.css
+++ b/app/Views/_assets/styles/index.css
@@ -10,3 +10,4 @@
 @import "./note.css";
 @import "./tabs.css";
 @import "./radioToggler.css";
+@import "./formInputTabs.css";
diff --git a/app/Views/_assets/styles/tabs.css b/app/Views/_assets/styles/tabs.css
index 4414c5174e..f0f279b48b 100644
--- a/app/Views/_assets/styles/tabs.css
+++ b/app/Views/_assets/styles/tabs.css
@@ -7,7 +7,7 @@
     @apply absolute -left-full;
   }
 
-  .tab-panel {
+  .tabset .tab-panel {
     @apply hidden;
   }
 
@@ -31,7 +31,7 @@
     @apply font-semibold opacity-100 text-pine-700;
   }
 
-  .tab-panels {
+  .tabset .tab-panels {
     @apply col-span-2 p-6;
   }
 }
diff --git a/app/Views/admin/episode/create.php b/app/Views/admin/episode/create.php
index 4d6061c924..883af9f72c 100644
--- a/app/Views/admin/episode/create.php
+++ b/app/Views/admin/episode/create.php
@@ -29,14 +29,14 @@
 ) ?>
 
 <?= form_label(
-    lang('Episode.form.enclosure'),
-    'enclosure',
+    lang('Episode.form.audio_file'),
+    'audio_file',
     [],
-    lang('Episode.form.enclosure_hint'),
+    lang('Episode.form.audio_file_hint'),
 ) ?>
 <?= form_input([
-    'id' => 'enclosure',
-    'name' => 'enclosure',
+    'id' => 'audio_file',
+    'name' => 'audio_file',
     'class' => 'form-input mb-4',
     'required' => 'required',
     'type' => 'file',
@@ -263,34 +263,133 @@
     lang('Episode.form.additional_files_section_title'),
     lang('Episode.form.additional_files_section_subtitle'),
 ) ?>
-<?= form_label(
-    lang('Episode.form.transcript'),
-    'transcript',
-    [],
-    lang('Episode.form.transcript_hint'),
-    true,
-) ?>
-<?= form_input([
-    'id' => 'transcript',
-    'name' => 'transcript',
-    'class' => 'form-input mb-4',
-    'type' => 'file',
-    'accept' => '.txt,.html,.srt,.json',
-]) ?>
-<?= form_label(
-    lang('Episode.form.chapters'),
-    'chapters',
-    [],
-    lang('Episode.form.chapters_hint'),
-    true,
-) ?>
-<?= form_input([
-    'id' => 'chapters',
-    'name' => 'chapters',
-    'class' => 'form-input mb-4',
-    'type' => 'file',
-    'accept' => '.json',
-]) ?>
+
+<?= form_fieldset('', ['class' => 'flex flex-col mb-4']) ?>
+    <legend><?= lang('Episode.form.transcript') .
+        '<small class="ml-1 lowercase">(' .
+        lang('Common.optional') .
+        ')</small>' .
+        hint_tooltip(lang('Episode.form.transcript_hint'), 'ml-1') ?></legend>
+    <div class="mb-4 form-input-tabs">
+        <input type="radio" name="transcript-choice" id="transcript-file-upload-choice" aria-controls="transcript-file-upload-choice" value="upload-file" <?= old(
+            'transcript-choice',
+        ) !== 'remote-url'
+            ? 'checked'
+            : '' ?> />
+        <label for="transcript-file-upload-choice"><?= lang(
+            'Common.forms.upload_file',
+        ) ?></label>
+
+        <input type="radio" name="transcript-choice" id="transcript-file-remote-url-choice" aria-controls="transcript-file-remote-url-choice" value="remote-url" <?= old(
+            'transcript-choice',
+        ) === 'remote-url'
+            ? 'checked'
+            : '' ?> />
+        <label for="transcript-file-remote-url-choice"><?= lang(
+            'Common.forms.remote_url',
+        ) ?></label>
+
+        <div class="py-2 tab-panels">
+            <section id="transcript-file-upload" class="flex items-center tab-panel">
+            <?= form_label(
+                lang('Episode.form.transcript_file'),
+                'transcript_file',
+                ['class' => 'sr-only'],
+                lang('Episode.form.transcript_file'),
+                true,
+            ) ?>
+            <?= form_input([
+                'id' => 'transcript_file',
+                'name' => 'transcript_file',
+                'class' => 'form-input',
+                'type' => 'file',
+                'accept' => '.txt,.html,.srt,.json',
+            ]) ?>
+            </section>
+            <section id="transcript-file-remote-url" class="tab-panel">
+            <?= form_label(
+                lang('Episode.form.transcript_file_remote_url'),
+                'transcript_file_remote_url',
+                ['class' => 'sr-only'],
+                lang('Episode.form.transcript_file_remote_url'),
+                true,
+            ) ?>
+            <?= form_input([
+                'id' => 'transcript_file_remote_url',
+                'name' => 'transcript_file_remote_url',
+                'class' => 'form-input w-full',
+                'type' => 'url',
+                'placeholder' => 'https://...',
+                'value' => old('transcript_file_remote_url'),
+            ]) ?>
+            </section>
+        </div>
+    </div>
+<?= form_fieldset_close() ?>
+
+<?= form_fieldset('', ['class' => 'flex flex-col mb-4']) ?>
+    <legend><?= lang('Episode.form.chapters') .
+        '<small class="ml-1 lowercase">(' .
+        lang('Common.optional') .
+        ')</small>' .
+        hint_tooltip(lang('Episode.form.chapters_hint'), 'ml-1') ?></legend>
+    <div class="mb-4 form-input-tabs">
+        <input type="radio" name="chapters-choice" id="chapters-file-upload-choice" aria-controls="chapters-file-upload-choice" value="upload-file" <?= old(
+            'chapters-choice',
+        ) !== 'remote-url'
+            ? 'checked'
+            : '' ?> />
+        <label for="chapters-file-upload-choice"><?= lang(
+            'Common.forms.upload_file',
+        ) ?></label>
+
+        <input type="radio" name="chapters-choice" id="chapters-file-remote-url-choice" aria-controls="chapters-file-remote-url-choice" value="remote-url" <?= old(
+            'chapters-choice',
+        ) === 'remote-url'
+            ? 'checked'
+            : '' ?> />
+        <label for="chapters-file-remote-url-choice"><?= lang(
+            'Common.forms.remote_url',
+        ) ?></label>
+
+        <div class="py-2 tab-panels">
+            <section id="chapters-file-upload" class="flex items-center tab-panel">
+            <?= form_label(
+                lang('Episode.form.chapters_file'),
+                'chapters_file',
+                ['class' => 'sr-only'],
+                lang('Episode.form.chapters_file'),
+                true,
+            ) ?>
+            <?= form_input([
+                'id' => 'chapters_file',
+                'name' => 'chapters_file',
+                'class' => 'form-input',
+                'type' => 'file',
+                'accept' => '.json',
+            ]) ?>
+            </section>
+            <section id="chapters-file-remote-url" class="tab-panel">
+            <?= form_label(
+                lang('Episode.form.chapters_file_remote_url'),
+                'chapters_file_remote_url',
+                ['class' => 'sr-only'],
+                lang('Episode.form.chapters_file_remote_url'),
+                true,
+            ) ?>
+            <?= form_input([
+                'id' => 'chapters_file_remote_url',
+                'name' => 'chapters_file_remote_url',
+                'class' => 'form-input w-full',
+                'type' => 'url',
+                'placeholder' => 'https://...',
+                'value' => old('chapters_file_remote_url'),
+            ]) ?>
+            </section>
+        </div>
+    </div>
+<?= form_fieldset_close() ?>
+
 <?= form_section_close() ?>
 
 <?= form_section(
diff --git a/app/Views/admin/episode/edit.php b/app/Views/admin/episode/edit.php
index 7a75b95107..1965b0c68c 100644
--- a/app/Views/admin/episode/edit.php
+++ b/app/Views/admin/episode/edit.php
@@ -24,18 +24,26 @@
 
 <?= form_section(
     lang('Episode.form.info_section_title'),
-    lang('Episode.form.info_section_subtitle'),
+    '<img
+    src="' .
+        $episode->image->medium_url .
+        '"
+    alt="' .
+        $episode->title .
+        '"
+    class="w-48"
+/>',
 ) ?>
 
 <?= form_label(
-    lang('Episode.form.enclosure'),
-    'enclosure',
+    lang('Episode.form.audio_file'),
+    'audio_file',
     [],
-    lang('Episode.form.enclosure_hint'),
+    lang('Episode.form.audio_file_hint'),
 ) ?>
 <?= form_input([
-    'id' => 'enclosure',
-    'name' => 'enclosure',
+    'id' => 'audio_file',
+    'name' => 'audio_file',
     'class' => 'form-input mb-4',
     'type' => 'file',
     'accept' => '.mp3,.m4a',
@@ -48,11 +56,7 @@
     lang('Episode.form.image_hint'),
     true,
 ) ?>
-<img
-    src="<?= $episode->image->thumbnail_url ?>"
-    alt="<?= $episode->title ?>"
-    class="object-cover w-32 h-32"
-/>
+
 <?= form_input([
     'id' => 'image',
     'name' => 'image',
@@ -272,86 +276,192 @@
             '“<a href="https://github.com/Podcastindex-org/podcast-namespace" target="_blank" rel="noreferrer noopener" style="text-decoration: underline;">podcast namespace</a>”',
     ]),
 ) ?>
-<div class="flex flex-col flex-1">
-<?= form_label(
-    lang('Episode.form.transcript'),
-    'transcript',
-    [],
-    lang('Episode.form.transcript_hint'),
-    true,
-) ?>
-<?php if ($episode->transcript): ?>
-    <div class="flex justify-between">
-        <?= anchor(
-            $episode->transcriptUrl,
-            icon('file', 'mr-2') . $episode->transcript,
-            [
-                'class' => 'inline-flex items-center text-xs',
-                'target' => '_blank',
-                'rel' => 'noreferrer noopener',
-            ],
-        ) .
-            anchor(
-                route_to('transcript-delete', $podcast->id, $episode->id),
-                icon('delete-bin', 'mx-auto'),
-                [
-                    'class' =>
-                        'p-1 bg-red-200 rounded-full text-red-700 hover:text-red-900',
-                    'data-toggle' => 'tooltip',
-                    'data-placement' => 'bottom',
-                    'title' => lang('Episode.form.transcript_delete'),
-                ],
+
+<?= form_fieldset('', ['class' => 'flex flex-col mb-4']) ?>
+    <legend><?= lang('Episode.form.transcript') .
+        '<small class="ml-1 lowercase">(' .
+        lang('Common.optional') .
+        ')</small>' .
+        hint_tooltip(lang('Episode.form.transcript_hint'), 'ml-1') ?></legend>
+    <div class="mb-4 form-input-tabs">
+        <input type="radio" name="transcript-choice" id="transcript-file-upload-choice" aria-controls="transcript-file-upload-choice" value="upload-file" <?= !$episode->transcript_file_remote_url
+            ? 'checked'
+            : '' ?> />
+        <label for="transcript-file-upload-choice"><?= lang(
+            'Common.forms.upload_file',
+        ) ?></label>
+
+        <input type="radio" name="transcript-choice" id="transcript-file-remote-url-choice" aria-controls="transcript-file-remote-url-choice" value="remote-url" <?= $episode->transcript_file_remote_url
+            ? 'checked'
+            : '' ?> />
+        <label for="transcript-file-remote-url-choice"><?= lang(
+            'Common.forms.remote_url',
+        ) ?></label>
+
+        <div class="py-2 tab-panels">
+            <section id="transcript-file-upload" class="flex items-center tab-panel">
+            <?php if ($episode->transcript_file): ?>
+                <div class="flex justify-between">
+                    <?= anchor(
+                        $episode->transcript_file_url,
+                        icon('file', 'mr-2 text-gray-500') .
+                            $episode->transcript_file,
+                        [
+                            'class' => 'inline-flex items-center text-xs',
+                            'target' => '_blank',
+                            'rel' => 'noreferrer noopener',
+                        ],
+                    ) .
+                        anchor(
+                            route_to(
+                                'transcript-delete',
+                                $podcast->id,
+                                $episode->id,
+                            ),
+                            icon('delete-bin', 'mx-auto'),
+                            [
+                                'class' =>
+                                    'p-1 bg-red-200 rounded-full text-red-700 hover:text-red-900',
+                                'data-toggle' => 'tooltip',
+                                'data-placement' => 'bottom',
+                                'title' => lang(
+                                    'Episode.form.transcript_file_delete',
+                                ),
+                            ],
+                        ) ?>
+                </div>
+            <?php endif; ?>
+            <?= form_label(
+                lang('Episode.form.transcript_file'),
+                'transcript_file',
+                ['class' => 'sr-only'],
+                lang('Episode.form.transcript_file'),
+                true,
             ) ?>
+            <?= form_input([
+                'id' => 'transcript_file',
+                'name' => 'transcript_file',
+                'class' => 'form-input',
+                'type' => 'file',
+                'accept' => '.txt,.html,.srt,.json',
+            ]) ?>
+            </section>
+            <section id="transcript-file-remote-url" class="tab-panel">
+            <?= form_label(
+                lang('Episode.form.transcript_file_remote_url'),
+                'transcript_file_remote_url',
+                ['class' => 'sr-only'],
+                lang('Episode.form.transcript_file_remote_url'),
+                true,
+            ) ?>
+            <?= form_input([
+                'id' => 'transcript_file_remote_url',
+                'name' => 'transcript_file_remote_url',
+                'class' => 'form-input w-full',
+                'type' => 'url',
+                'placeholder' => 'https://...',
+                'value' => old(
+                    'transcript_file_remote_url',
+                    $episode->transcript_file_remote_url,
+                ),
+            ]) ?>
+            </section>
+        </div>
     </div>
-<?php endif; ?>
-<?= form_input([
-    'id' => 'transcript',
-    'name' => 'transcript',
-    'class' => 'form-input mb-4',
-    'type' => 'file',
-    'accept' => '.txt,.html,.srt,.json',
-]) ?>
-</div>
-<div class="flex flex-col flex-1">
-<?= form_label(
-    lang('Episode.form.chapters'),
-    'chapters',
-    [],
-    lang('Episode.form.chapters_hint'),
-    true,
-) ?>
-<?php if ($episode->chapters): ?>
-    <div class="flex justify-between">
-        <?= anchor(
-            $episode->chaptersUrl,
-            icon('file', 'mr-2') . $episode->chapters,
-            [
-                'class' => 'inline-flex items-center text-xs',
-                'target' => '_blank',
-                'rel' => 'noreferrer noopener',
-            ],
-        ) .
-            anchor(
-                route_to('chapters-delete', $podcast->id, $episode->id),
-                icon('delete-bin', 'mx-auto'),
-                [
-                    'class' =>
-                        'p-1 bg-red-200 rounded-full text-red-700 hover:text-red-900',
-                    'data-toggle' => 'tooltip',
-                    'data-placement' => 'bottom',
-                    'title' => lang('Episode.form.chapters_delete'),
-                ],
+<?= form_fieldset_close() ?>
+
+<?= form_fieldset('', ['class' => 'flex flex-col mb-4']) ?>
+    <legend><?= lang('Episode.form.chapters') .
+        '<small class="ml-1 lowercase">(' .
+        lang('Common.optional') .
+        ')</small>' .
+        hint_tooltip(lang('Episode.form.chapters_hint'), 'ml-1') ?></legend>
+    <div class="mb-4 form-input-tabs">
+        <input type="radio" name="chapters-choice" id="chapters-file-upload-choice" aria-controls="chapters-file-upload-choice" value="upload-file" <?= !$episode->chapters_file_remote_url
+            ? 'checked'
+            : '' ?> />
+        <label for="chapters-file-upload-choice"><?= lang(
+            'Common.forms.upload_file',
+        ) ?></label>
+
+        <input type="radio" name="chapters-choice" id="chapters-file-remote-url-choice" aria-controls="chapters-file-remote-url-choice" value="remote-url" <?= $episode->chapters_file_remote_url
+            ? 'checked'
+            : '' ?> />
+        <label for="chapters-file-remote-url-choice"><?= lang(
+            'Common.forms.remote_url',
+        ) ?></label>
+
+        <div class="py-2 tab-panels">
+            <section id="chapters-file-upload" class="flex items-center tab-panel">
+            <?php if ($episode->chapters_file): ?>
+                <div class="flex justify-between">
+                    <?= anchor(
+                        $episode->chapters_file_url,
+                        icon('file', 'mr-2') . $episode->chapters_file,
+                        [
+                            'class' => 'inline-flex items-center text-xs',
+                            'target' => '_blank',
+                            'rel' => 'noreferrer noopener',
+                        ],
+                    ) .
+                        anchor(
+                            route_to(
+                                'chapters-delete',
+                                $podcast->id,
+                                $episode->id,
+                            ),
+                            icon('delete-bin', 'mx-auto'),
+                            [
+                                'class' =>
+                                    'p-1 bg-red-200 rounded-full text-red-700 hover:text-red-900',
+                                'data-toggle' => 'tooltip',
+                                'data-placement' => 'bottom',
+                                'title' => lang(
+                                    'Episode.form.chapters_file_delete',
+                                ),
+                            ],
+                        ) ?>
+                </div>
+            <?php endif; ?>
+            <?= form_label(
+                lang('Episode.form.chapters_file'),
+                'chapters_file',
+                ['class' => 'sr-only'],
+                lang('Episode.form.chapters_file'),
+                true,
+            ) ?>
+            <?= form_input([
+                'id' => 'chapters_file',
+                'name' => 'chapters_file',
+                'class' => 'form-input',
+                'type' => 'file',
+                'accept' => '.json',
+            ]) ?>
+            </section>
+            <section id="chapters-file-remote-url" class="tab-panel">
+            <?= form_label(
+                lang('Episode.form.chapters_file_remote_url'),
+                'chapters_file_remote_url',
+                ['class' => 'sr-only'],
+                lang('Episode.form.chapters_file_remote_url'),
+                true,
             ) ?>
+            <?= form_input([
+                'id' => 'chapters_file_remote_url',
+                'name' => 'chapters_file_remote_url',
+                'class' => 'form-input w-full',
+                'type' => 'url',
+                'placeholder' => 'https://...',
+                'value' => old(
+                    'chapters_file_remote_url',
+                    $episode->chapters_file_remote_url,
+                ),
+            ]) ?>
+            </section>
+        </div>
     </div>
-<?php endif; ?>
-<?= form_input([
-    'id' => 'chapters',
-    'name' => 'chapters',
-    'class' => 'form-input mb-4',
-    'type' => 'file',
-    'accept' => '.json',
-]) ?>
-</div>
+<?= form_fieldset_close() ?>
+
 <?= form_section_close() ?>
 
 <?= form_section(
diff --git a/app/Views/admin/episode/list.php b/app/Views/admin/episode/list.php
index ef0650c250..d0f455d3ff 100644
--- a/app/Views/admin/episode/list.php
+++ b/app/Views/admin/episode/list.php
@@ -107,12 +107,14 @@
                             $episode->publication_status,
                         ) ?>
                         <span class="mx-1">•</span>
-                        <time datetime="PT<?= $episode->enclosure_duration ?>S">
-                            <?= format_duration($episode->enclosure_duration) ?>
+                        <time datetime="PT<?= $episode->audio_file_duration ?>S">
+                            <?= format_duration(
+                                $episode->audio_file_duration,
+                            ) ?>
                         </time>
                     </div>
                     <audio controls preload="none" class="w-full mt-auto">
-                        <source src="/<?= $episode->enclosure_media_path ?>" type="<?= $episode->enclosure_type ?>">
+                        <source src="<?= $episode->audio_file_url ?>" type="<?= $episode->audio_file_mimetype ?>">
                         Your browser does not support the audio tag.
                     </audio>
                 </div>
diff --git a/app/Views/admin/episode/publish.php b/app/Views/admin/episode/publish.php
index f491092b3b..4cc1d7c71a 100644
--- a/app/Views/admin/episode/publish.php
+++ b/app/Views/admin/episode/publish.php
@@ -62,15 +62,15 @@
                     ) ?>
                 </div>
                 <div class="text-xs text-gray-600">
-                    <time datetime="PT<?= $episode->enclosure_duration ?>S">
-                        <?= format_duration($episode->enclosure_duration) ?>
+                    <time datetime="PT<?= $episode->audio_file_duration ?>S">
+                        <?= format_duration($episode->audio_file_duration) ?>
                     </time>
                 </div>
             </a>
             <audio controls preload="none" class="w-full mt-auto">
                 <source
-                src="<?= $episode->enclosure_web_url ?>"
-                type="<?= $episode->enclosure_mimetype ?>">
+                src="<?= $episode->audio_file_url ?>"
+                type="<?= $episode->audio_file_mimetype ?>">
                 Your browser does not support the audio tag.
             </audio>
         </div>
diff --git a/app/Views/admin/episode/publish_edit.php b/app/Views/admin/episode/publish_edit.php
index 64246aef0d..973dc5d546 100644
--- a/app/Views/admin/episode/publish_edit.php
+++ b/app/Views/admin/episode/publish_edit.php
@@ -78,13 +78,13 @@
                         ]) ?>
                     </time>
                     <span class="mx-1">•</span>
-                    <time datetime="PT<?= $episode->enclosure_duration ?>S">
-                        <?= format_duration($episode->enclosure_duration) ?>
+                    <time datetime="PT<?= $episode->audio_file_duration ?>S">
+                        <?= format_duration($episode->audio_file_duration) ?>
                     </time>
                 </div>
             </a>
             <audio controls preload="none" class="w-full mt-auto">
-                <source src="<?= $episode->enclosure_web_url ?>" type="<?= $episode->enclosure_mimetype ?>">
+                <source src="<?= $episode->audio_file_url ?>" type="<?= $episode->audio_file_mimetype ?>">
                 Your browser does not support the audio tag.
             </audio>
         </div>
diff --git a/app/Views/admin/episode/soundbites.php b/app/Views/admin/episode/soundbites.php
index 1a5ff48404..e4893e77a6 100644
--- a/app/Views/admin/episode/soundbites.php
+++ b/app/Views/admin/episode/soundbites.php
@@ -13,13 +13,13 @@
 
 <?= form_open_multipart(
     route_to('episode-soundbites-edit', $podcast->id, $episode->id),
-    ['method' => 'post', 'class' => 'flex flex-col']
+    ['method' => 'post', 'class' => 'flex flex-col'],
 ) ?>
 <?= csrf_field() ?>
 
 <?= form_section(
     lang('Episode.soundbites_form.info_section_title'),
-    lang('Episode.soundbites_form.info_section_subtitle')
+    lang('Episode.soundbites_form.info_section_subtitle'),
 ) ?>
 
     <table class="w-full table-fixed">
@@ -30,20 +30,20 @@
                 lang('Episode.soundbites_form.start_time'),
                 'start_time',
                 [],
-                lang('Episode.soundbites_form.start_time_hint')
+                lang('Episode.soundbites_form.start_time_hint'),
             ) ?></th>
             <th class="w-3/12 px-1 py-2"><?= form_label(
                 lang('Episode.soundbites_form.duration'),
                 'duration',
                 [],
-                lang('Episode.soundbites_form.duration_hint')
+                lang('Episode.soundbites_form.duration_hint'),
             ) ?></th>
             <th class="w-7/12 px-1 py-2"><?= form_label(
                 lang('Episode.soundbites_form.label'),
                 'label',
                 [],
                 lang('Episode.soundbites_form.label_hint'),
-                true
+                true,
             ) ?></th>
             <th class="w-1/12 px-1 py-2"></th>
             </tr>
@@ -62,7 +62,7 @@
                     'data-soundbite-id' => $soundbite->id,
                     'required' => 'required',
                     'min' => '0',
-                ]
+                ],
             ) ?></td>
             <td class="px-1 py-2 font-medium bg-white border border-light-blue-500"><?= form_input(
                 [
@@ -75,7 +75,7 @@
                     'data-soundbite-id' => $soundbite->id,
                     'required' => 'required',
                     'min' => '0',
-                ]
+                ],
             ) ?></td>
             <td class="px-1 py-2 font-medium bg-white border border-light-blue-500"><?= form_input(
                 [
@@ -83,7 +83,7 @@
                     'name' => "soundbites_array[{$soundbite->id}][label]",
                     'class' => 'form-input w-full border-none',
                     'value' => $soundbite->label,
-                ]
+                ],
             ) ?></td>
             <td class="px-4 py-2"><?= icon_button(
                 'play',
@@ -96,7 +96,7 @@
                     'data-soundbite-id' => $soundbite->id,
                     'data-soundbite-start-time' => $soundbite->start_time,
                     'data-soundbite-duration' => $soundbite->duration,
-                ]
+                ],
             ) ?>
             <?= icon_button(
                 'delete-bin',
@@ -105,10 +105,10 @@
                     'soundbite-delete',
                     $podcast->id,
                     $episode->id,
-                    $soundbite->id
+                    $soundbite->id,
                 ),
                 ['variant' => 'danger'],
-                []
+                [],
             ) ?>    
             </td>
         </tr>
@@ -124,7 +124,7 @@
                 'data-type' => 'soundbite-field',
                 'data-field-type' => 'start-time',
                 'min' => '0',
-            ]
+            ],
         ) ?></td>
         <td class="px-1 py-4 font-medium bg-white border border-light-blue-500"><?= form_input(
             [
@@ -136,7 +136,7 @@
                 'data-type' => 'soundbite-field',
                 'data-field-type' => 'duration',
                 'min' => '0',
-            ]
+            ],
         ) ?></td>
         <td class="px-1 py-4 font-medium bg-white border border-light-blue-500"><?= form_input(
             [
@@ -144,7 +144,7 @@
                 'name' => 'soundbites_array[0][label]',
                 'class' => 'form-input w-full border-none',
                 'value' => old('label'),
-            ]
+            ],
         ) ?></td>
         <td class="px-4 py-2"><?= icon_button(
             'play',
@@ -156,7 +156,7 @@
                 'data-soundbite-id' => 0,
                 'data-soundbite-start-time' => 0,
                 'data-soundbite-duration' => 0,
-            ]
+            ],
         ) ?>
             
                     
@@ -164,7 +164,7 @@
         </tr>
         <tr><td colspan="3">
             <audio controls preload="auto" class="w-full">
-                <source src="/<?= $episode->enclosure_media_path ?>" type="<?= $episode->enclosure_type ?>">
+                <source src="<?= $episode->audio_file_url ?>" type="<?= $episode->audio_file_mimetype ?>">
         Your browser does not support the audio tag.
             </audio>
         </td><td class="px-4 py-2"><?= icon_button(
@@ -177,7 +177,7 @@
                 'data-start-time-field-name' =>
                     'soundbites_array[0][start_time]',
                 'data-duration-field-name' => 'soundbites_array[0][duration]',
-            ]
+            ],
         ) ?></td></tr>
     </tbody>
     </table>
@@ -189,7 +189,7 @@
     lang('Episode.soundbites_form.submit_edit'),
     null,
     ['variant' => 'primary'],
-    ['type' => 'submit', 'class' => 'self-end']
+    ['type' => 'submit', 'class' => 'self-end'],
 ) ?>
 
 <?= form_close() ?>
diff --git a/app/Views/admin/episode/view.php b/app/Views/admin/episode/view.php
index cd90f1cc00..c5a0a88323 100644
--- a/app/Views/admin/episode/view.php
+++ b/app/Views/admin/episode/view.php
@@ -39,7 +39,7 @@
             class="object-cover w-full"
         />
         <audio controls preload="auto" class="w-full mb-6">
-        <source src="/<?= $episode->enclosure_media_path ?>" type="<?= $episode->enclosure_type ?>">
+        <source src="<?= $episode->audio_file_url ?>" type="<?= $episode->audio_file_mimetype ?>">
         Your browser does not support the audio tag.
         </audio>
 
diff --git a/app/Views/embeddable_player.php b/app/Views/embeddable_player.php
index b9d9365a37..59fd77598d 100644
--- a/app/Views/embeddable_player.php
+++ b/app/Views/embeddable_player.php
@@ -50,11 +50,11 @@
             ) ?>
         </a>
         <audio controls preload="none" class="flex w-full mt-auto">
-            <source src="<?= $episode->enclosure_url .
+            <source src="<?= $episode->audio_file_analytics_url .
                 (isset($_SERVER['HTTP_REFERER'])
                     ? '?_from=' .
                         parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST)
-                    : '') ?>" type="<?= $episode->enclosure_type ?>" />
+                    : '') ?>" type="<?= $episode->audio_file_mimetype ?>" />
             Your browser does not support the audio tag.
         </audio>
     </div>
diff --git a/app/Views/podcast/_partials/episode_card.php b/app/Views/podcast/_partials/episode_card.php
index a3bec75b23..c64e82bde1 100644
--- a/app/Views/podcast/_partials/episode_card.php
+++ b/app/Views/podcast/_partials/episode_card.php
@@ -21,15 +21,15 @@
                 <?= lang('Common.mediumDate', [$episode->published_at]) ?>
                 </time>
                 <span class="mx-1">•</span>
-                <time datetime="PT<?= $episode->enclosure_duration ?>S">
-                    <?= format_duration($episode->enclosure_duration) ?>
+                <time datetime="PT<?= $episode->audio_file_duration ?>S">
+                    <?= format_duration($episode->audio_file_duration) ?>
                 </time>
             </div>
         </a>
         <audio controls preload="none" class="w-full mt-auto">
             <source
-            src="<?= $episode->enclosure_web_url ?>"
-            type="<?= $episode->enclosure_mimetype ?>">
+            src="<?= $episode->audio_file_web_url ?>"
+            type="<?= $episode->audio_file_mimetype ?>">
             Your browser does not support the audio tag.
         </audio>
     </div>
diff --git a/app/Views/podcast/episode.php b/app/Views/podcast/episode.php
index 464e24415f..005e8cbf3e 100644
--- a/app/Views/podcast/episode.php
+++ b/app/Views/podcast/episode.php
@@ -17,8 +17,8 @@
 <meta property="og:description" content="$description" />
 <meta property="article:published_time" content="<?= $episode->published_at ?>" />
 <meta property="article:modified_time" content="<?= $episode->updated_at ?>" />
-<meta property="og:audio" content="<?= $episode->enclosure_opengraph_url ?>" />
-<meta property="og:audio:type" content="<?= $episode->enclosure_mimetype ?>" />
+<meta property="og:audio" content="<?= $episode->audio_file_opengraph_url ?>" />
+<meta property="og:audio:type" content="<?= $episode->audio_file_mimetype ?>" />
 <link rel="alternate" type="application/json+oembed" href="<?= base_url(
     route_to('episode-oembed-json', $podcast->name, $episode->slug),
 ) ?>" title="<?= $episode->title ?> oEmbed json" />
@@ -67,8 +67,8 @@
                         ]) ?>
                     </time>
                     <span class="mx-1">•</span>
-                    <time datetime="PT<?= $episode->enclosure_duration ?>S">
-                        <?= format_duration($episode->enclosure_duration) ?>
+                    <time datetime="PT<?= $episode->audio_file_duration ?>S">
+                        <?= format_duration($episode->audio_file_duration) ?>
                     </time>
                 </div>
                 <div class="mb-2 space-x-4 text-sm">
@@ -150,7 +150,7 @@ data-placement="bottom" title="[<?= $person['full_name'] ?>] <?= $person[
             </div>
         </div>
         <audio controls preload="none" class="w-full mt-auto">
-            <source src="<?= $episode->enclosure_web_url ?>" type="<?= $episode->enclosure_type ?>">
+            <source src="<?= $episode->audio_file_web_url ?>" type="<?= $episode->audio_file_mimetype ?>">
             Your browser does not support the audio tag.
         </audio>
     </header>
diff --git a/app/Views/podcast/episode_authenticated.php b/app/Views/podcast/episode_authenticated.php
index b8c4b90398..38ea9ab811 100644
--- a/app/Views/podcast/episode_authenticated.php
+++ b/app/Views/podcast/episode_authenticated.php
@@ -17,8 +17,8 @@
 <meta property="og:description" content="$description" />
 <meta property="article:published_time" content="<?= $episode->published_at ?>" />
 <meta property="article:modified_time" content="<?= $episode->updated_at ?>" />
-<meta property="og:audio" content="<?= $episode->enclosure_opengraph_url ?>" />
-<meta property="og:audio:type" content="<?= $episode->enclosure_mimetype ?>" />
+<meta property="og:audio" content="<?= $episode->audio_file_opengraph_url ?>" />
+<meta property="og:audio:type" content="<?= $episode->audio_file_mimetype ?>" />
 <link rel="alternate" type="application/json+oembed" href="<?= base_url(
     route_to('episode-oembed-json', $podcast->name, $episode->slug),
 ) ?>" title="<?= $episode->title ?> oEmbed json" />
@@ -67,8 +67,8 @@
                         ]) ?>
                     </time>
                     <span class="mx-1">•</span>
-                    <time datetime="PT<?= $episode->enclosure_duration ?>S">
-                        <?= format_duration($episode->enclosure_duration) ?>
+                    <time datetime="PT<?= $episode->audio_file_duration ?>S">
+                        <?= format_duration($episode->audio_file_duration) ?>
                     </time>
                 </div>
                 <div class="mb-2 space-x-4 text-sm">
@@ -148,7 +148,7 @@
             </div>
         </div>
         <audio controls preload="none" class="w-full mt-auto">
-            <source src="<?= $episode->enclosure_web_url ?>" type="<?= $episode->enclosure_type ?>">
+            <source src="<?= $episode->audio_file_web_url ?>" type="<?= $episode->audio_file_mimetype ?>">
             Your browser does not support the audio tag.
         </audio>
     </header>
diff --git a/app/Views/podcast/episodes.php b/app/Views/podcast/episodes.php
index 8e76c01f6e..5496241b6f 100644
--- a/app/Views/podcast/episodes.php
+++ b/app/Views/podcast/episodes.php
@@ -105,14 +105,14 @@
                                 ]) ?>
                             </time>
                             <span class="mx-1">•</span>
-                            <time datetime="PT<?= $episode->enclosure_duration ?>S">
+                            <time datetime="PT<?= $episode->audio_file_duration ?>S">
                                 <?= format_duration(
-                                    $episode->enclosure_duration,
+                                    $episode->audio_file_duration,
                                 ) ?>
                             </time>
                         </div>
                         <audio controls preload="none" class="w-full mt-auto">
-                            <source src="<?= $episode->enclosure_web_url ?>" type="<?= $episode->enclosure_mimetype ?>">
+                            <source src="<?= $episode->audio_file_web_url ?>" type="<?= $episode->audio_file_mimetype ?>">
                             Your browser does not support the audio tag.
                         </audio>
                     </div>
@@ -168,4 +168,5 @@
     <?php endif; ?>
 </section>
 
-<?= $this->endSection() ?>
+<?= $this->endSection()
+?>
diff --git a/app/Views/podcast/episodes_authenticated.php b/app/Views/podcast/episodes_authenticated.php
index 1d37f1a5b8..9f63b0cd08 100644
--- a/app/Views/podcast/episodes_authenticated.php
+++ b/app/Views/podcast/episodes_authenticated.php
@@ -105,14 +105,14 @@
                                 ]) ?>
                             </time>
                             <span class="mx-1">•</span>
-                            <time datetime="PT<?= $episode->enclosure_duration ?>S">
+                            <time datetime="PT<?= $episode->audio_file_duration ?>S">
                                 <?= format_duration(
-                                    $episode->enclosure_duration,
+                                    $episode->audio_file_duration,
                                 ) ?>
                             </time>
                         </div>
                         <audio controls preload="none" class="w-full mt-auto">
-                            <source src="<?= $episode->enclosure_web_url ?>" type="<?= $episode->enclosure_mimetype ?>">
+                            <source src="<?= $episode->audio_file_web_url ?>" type="<?= $episode->audio_file_mimetype ?>">
                             Your browser does not support the audio tag.
                         </audio>
                     </div>
-- 
GitLab