diff --git a/Dockerfile b/Dockerfile
index 723d1bc2ece1a2310cda5bebaa06d4e14b142281..0b3f2e6c78c75a7c8b201bdc515c9e7163df8b2f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -50,6 +50,8 @@ RUN apt-get update \
     # gd for image processing
     && docker-php-ext-configure gd --with-webp --with-jpeg --with-freetype \
     && docker-php-ext-install gd \
+    && docker-php-ext-install exif \
+    && docker-php-ext-enable exif \
     # redis extension for cache
     && pecl install -o -f redis \
     && rm -rf /tmp/pear \
diff --git a/app/Controllers/MapController.php b/app/Controllers/MapController.php
index c4a6cd4bde29f23f39d11e9d332a8a058c395c31..785a1352aeb4aafe53ce2094481e73997376e7c6 100644
--- a/app/Controllers/MapController.php
+++ b/app/Controllers/MapController.php
@@ -46,7 +46,7 @@ class MapController extends BaseController
                     'location_url' => $episode->location->url,
                     'episode_link' => $episode->link,
                     'podcast_link' => $episode->podcast->link,
-                    'cover_path' => $episode->cover->thumbnail_url,
+                    'cover_url' => $episode->cover->thumbnail_url,
                     'podcast_title' => $episode->podcast->title,
                     'episode_title' => $episode->title,
                 ];
diff --git a/app/Database/Migrations/2020-05-29-120000_add_media.php b/app/Database/Migrations/2020-05-29-120000_add_media.php
new file mode 100644
index 0000000000000000000000000000000000000000..711ba069f855c7bc66ecf5fc5c3871396fc213d7
--- /dev/null
+++ b/app/Database/Migrations/2020-05-29-120000_add_media.php
@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2021 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Database\Migrations;
+
+use CodeIgniter\Database\Migration;
+
+class AddMedia extends Migration
+{
+    public function up(): void
+    {
+        $this->forge->addField([
+            'id' => [
+                'type' => 'INT',
+                'unsigned' => true,
+                'auto_increment' => true,
+            ],
+            'file_path' => [
+                'type' => 'VARCHAR',
+                'constraint' => 255,
+            ],
+            'file_size' => [
+                'type' => 'INT',
+                'unsigned' => true,
+                'comment' => 'File size in bytes',
+            ],
+            'file_content_type' => [
+                'type' => 'VARCHAR',
+                'constraint' => 45,
+            ],
+            'file_metadata' => [
+                'type' => 'JSON',
+                'nullable' => true,
+            ],
+            'type' => [
+                'type' => 'ENUM',
+                'constraint' => ['image', 'audio', 'video', 'transcript', 'chapters', 'document'],
+            ],
+            'description' => [
+                'type' => 'TEXT',
+            ],
+            'language_code' => [
+                'type' => 'VARCHAR',
+                'constraint' => 2,
+            ],
+            'uploaded_by' => [
+                'type' => 'INT',
+                'unsigned' => true,
+            ],
+            'updated_by' => [
+                'type' => 'INT',
+                'unsigned' => true,
+            ],
+            'uploaded_at' => [
+                'type' => 'DATETIME',
+            ],
+            'updated_at' => [
+                'type' => 'DATETIME',
+            ],
+            'deleted_at' => [
+                'type' => 'DATETIME',
+                'null' => true,
+            ],
+        ]);
+
+        $this->forge->addKey('id', true);
+        $this->forge->addForeignKey('uploaded_by', 'users', 'id');
+        $this->forge->addForeignKey('updated_by', 'users', 'id');
+        $this->forge->createTable('media');
+    }
+
+    public function down(): void
+    {
+        $this->forge->dropTable('media');
+    }
+}
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 0663392a1b0f6dcfcb61923eea3ab69864723e03..ff2f913c470720ca8b526edef09e677a6e19edc4 100644
--- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
+++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
@@ -46,25 +46,13 @@ class AddPodcasts extends Migration
             'description_html' => [
                 'type' => 'TEXT',
             ],
-            'cover_path' => [
-                'type' => 'VARCHAR',
-                'constraint' => 255,
-            ],
-            // constraint is 13 because the longest safe mimetype for images is image/svg+xml,
-            // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#image_types
-            'cover_mimetype' => [
-                'type' => 'VARCHAR',
-                'constraint' => 13,
-            ],
-            'banner_path' => [
-                'type' => 'VARCHAR',
-                'constraint' => 255,
-                'null' => true,
-                'default' => null,
+            'cover_id' => [
+                'type' => 'INT',
+                'unsigned' => true,
             ],
-            'banner_mimetype' => [
-                'type' => 'VARCHAR',
-                'constraint' => 13,
+            'banner_id' => [
+                'type' => 'INT',
+                'unsigned' => true,
                 'null' => true,
                 'default' => null,
             ],
@@ -209,6 +197,8 @@ class AddPodcasts extends Migration
         $this->forge->addUniqueKey('guid');
         $this->forge->addUniqueKey('actor_id');
         $this->forge->addForeignKey('actor_id', config('Fediverse')->tablesPrefix . 'actors', 'id', '', 'CASCADE');
+        $this->forge->addForeignKey('cover_id', 'media', 'id');
+        $this->forge->addForeignKey('banner_id', 'media', 'id');
         $this->forge->addForeignKey('category_id', 'categories', 'id');
         $this->forge->addForeignKey('language_code', 'languages', 'code');
         $this->forge->addForeignKey('created_by', 'users', 'id');
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 aea2a023c375ef0bd4b503a8f383b7cad938f8c3..04656978eacce8a57e1bbbf4cbf456b0bb5d7405 100644
--- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php
+++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
@@ -40,29 +40,9 @@ class AddEpisodes extends Migration
                 'type' => 'VARCHAR',
                 'constraint' => 128,
             ],
-            'audio_file_path' => [
-                'type' => 'VARCHAR',
-                'constraint' => 255,
-            ],
-            'audio_file_duration' => [
-                // exact value for duration with max 99999,999 ~ 27.7 hours
-                'type' => 'DECIMAL(8,3)',
-                'unsigned' => true,
-                'comment' => 'Playtime in seconds',
-            ],
-            'audio_file_mimetype' => [
-                'type' => 'VARCHAR',
-                'constraint' => 255,
-            ],
-            'audio_file_size' => [
-                'type' => 'INT',
-                'unsigned' => true,
-                'comment' => 'File size in bytes',
-            ],
-            'audio_file_header_size' => [
+            'audio_id' => [
                 'type' => 'INT',
                 'unsigned' => true,
-                'comment' => 'Header size in bytes',
             ],
             'description_markdown' => [
                 'type' => 'TEXT',
@@ -70,34 +50,27 @@ class AddEpisodes extends Migration
             'description_html' => [
                 'type' => 'TEXT',
             ],
-            'cover_path' => [
-                'type' => 'VARCHAR',
-                'constraint' => 255,
-                'null' => true,
-            ],
-            // constraint is 13 because the longest safe mimetype for images is image/svg+xml,
-            // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#image_types
-            'cover_mimetype' => [
-                'type' => 'VARCHAR',
-                'constraint' => 13,
+            'cover_id' => [
+                'type' => 'INT',
+                'unsigned' => true,
                 'null' => true,
             ],
-            'transcript_file_path' => [
-                'type' => 'VARCHAR',
-                'constraint' => 255,
+            'transcript_id' => [
+                'type' => 'INT',
+                'unsigned' => true,
                 'null' => true,
             ],
-            'transcript_file_remote_url' => [
+            'transcript_remote_url' => [
                 'type' => 'VARCHAR',
                 'constraint' => 512,
                 'null' => true,
             ],
-            'chapters_file_path' => [
-                'type' => 'VARCHAR',
-                'constraint' => 255,
+            'chapters_id' => [
+                'type' => 'INT',
+                'unsigned' => true,
                 'null' => true,
             ],
-            'chapters_file_remote_url' => [
+            'chapters_remote_url' => [
                 'type' => 'VARCHAR',
                 'constraint' => 512,
                 'null' => true,
@@ -183,6 +156,10 @@ class AddEpisodes extends Migration
         $this->forge->addPrimaryKey('id');
         $this->forge->addUniqueKey(['podcast_id', 'slug']);
         $this->forge->addForeignKey('podcast_id', 'podcasts', 'id', '', 'CASCADE');
+        $this->forge->addForeignKey('audio_id', 'media', 'id');
+        $this->forge->addForeignKey('cover_id', 'media', 'id');
+        $this->forge->addForeignKey('transcript_id', 'media', 'id');
+        $this->forge->addForeignKey('chapters_id', 'media', 'id');
         $this->forge->addForeignKey('created_by', 'users', 'id');
         $this->forge->addForeignKey('updated_by', 'users', 'id');
         $this->forge->createTable('episodes');
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 58e26df3078b42ba57a05522de0b22fdb0219ee6..66b53ba1ad088a375d2de4eee336c959267d86e3 100644
--- a/app/Database/Migrations/2020-12-25-120000_add_persons.php
+++ b/app/Database/Migrations/2020-12-25-120000_add_persons.php
@@ -42,16 +42,9 @@ 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,
             ],
-            'avatar_path' => [
-                'type' => 'VARCHAR',
-                'constraint' => 255,
-                'null' => true,
-            ],
-            // constraint is 13 because the longest safe mimetype for images is image/svg+xml,
-            // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#image_types
-            'avatar_mimetype' => [
-                'type' => 'VARCHAR',
-                'constraint' => 13,
+            'avatar_id' => [
+                'type' => 'INT',
+                'unsigned' => true,
                 'null' => true,
             ],
             'created_by' => [
@@ -71,6 +64,7 @@ class AddPersons extends Migration
         ]);
 
         $this->forge->addKey('id', true);
+        $this->forge->addForeignKey('avatar_id', 'media', 'id');
         $this->forge->addForeignKey('created_by', 'users', 'id');
         $this->forge->addForeignKey('updated_by', 'users', 'id');
         $this->forge->createTable('persons');
diff --git a/app/Database/Migrations/2020-06-05-180000_add_soundbites.php b/app/Database/Migrations/2021-12-09-130000_add_clips.php
similarity index 71%
rename from app/Database/Migrations/2020-06-05-180000_add_soundbites.php
rename to app/Database/Migrations/2021-12-09-130000_add_clips.php
index 90573d51947e3070f9a537d018c9bc94719c7367..068c66b39dd9be1ccbebe7db4ef404bc7da5ee51 100644
--- a/app/Database/Migrations/2020-06-05-180000_add_soundbites.php
+++ b/app/Database/Migrations/2021-12-09-130000_add_clips.php
@@ -3,9 +3,7 @@
 declare(strict_types=1);
 
 /**
- * Class AddSoundbites Creates soundbites table in database
- *
- * @copyright  2020 Podlibre
+ * @copyright  2021 Podlibre
  * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
  * @link       https://castopod.org/
  */
@@ -14,7 +12,7 @@ namespace App\Database\Migrations;
 
 use CodeIgniter\Database\Migration;
 
-class AddSoundbites extends Migration
+class AddClips extends Migration
 {
     public function up(): void
     {
@@ -37,7 +35,7 @@ class AddSoundbites extends Migration
                 'unsigned' => true,
             ],
             'duration' => [
-                // soundbite duration cannot be higher than 9999,999 seconds ~ 2.77 hours
+                // clip duration cannot be higher than 9999,999 seconds ~ 2.77 hours
                 'type' => 'DECIMAL(7,3)',
                 'unsigned' => true,
             ],
@@ -46,6 +44,21 @@ class AddSoundbites extends Migration
                 'constraint' => 128,
                 'null' => true,
             ],
+            'type' => [
+                'type' => 'ENUM',
+                'constraint' => ['audio', 'video'],
+            ],
+            'media_id' => [
+                'type' => 'INT',
+                'unsigned' => true,
+            ],
+            'status' => [
+                'type' => 'ENUM',
+                'constraint' => ['queued', 'pending', 'generating', 'passed', 'failed'],
+            ],
+            'logs' => [
+                'type' => 'TEXT',
+            ],
             'created_by' => [
                 'type' => 'INT',
                 'unsigned' => true,
@@ -65,17 +78,19 @@ class AddSoundbites extends Migration
                 'null' => true,
             ],
         ]);
+
         $this->forge->addKey('id', true);
-        $this->forge->addUniqueKey(['episode_id', 'start_time', 'duration']);
+        $this->forge->addUniqueKey(['episode_id', 'start_time', 'duration', 'type']);
         $this->forge->addForeignKey('podcast_id', 'podcasts', 'id', '', 'CASCADE');
         $this->forge->addForeignKey('episode_id', 'episodes', 'id', '', 'CASCADE');
+        $this->forge->addForeignKey('media_id', 'media', 'id', '', 'CASCADE');
         $this->forge->addForeignKey('created_by', 'users', 'id');
         $this->forge->addForeignKey('updated_by', 'users', 'id');
-        $this->forge->createTable('soundbites');
+        $this->forge->createTable('clips');
     }
 
     public function down(): void
     {
-        $this->forge->dropTable('soundbites');
+        $this->forge->dropTable('clips');
     }
 }
diff --git a/app/Entities/Audio.php b/app/Entities/Audio.php
new file mode 100644
index 0000000000000000000000000000000000000000..4a342d41db30a5bb9eeacc3483b6ff442ecd0b62
--- /dev/null
+++ b/app/Entities/Audio.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2021 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Entities;
+
+use CodeIgniter\Files\File;
+use JamesHeinrich\GetID3\GetID3;
+
+/**
+ * @property float $duration
+ * @property int $header_size
+ */
+class Audio extends Media
+{
+    protected string $type = 'audio';
+
+    /**
+     * @param array<string, mixed>|null $data
+     */
+    public function __construct(array $data = null)
+    {
+        parent::__construct($data);
+
+        if ($this->file_metadata) {
+            $this->duration = (float) $this->file_metadata['playtime_seconds'];
+            $this->header_size = (int) $this->file_metadata['avdataoffset'];
+        }
+    }
+
+    public function setFile(File $file): self
+    {
+        parent::setFile($file);
+
+        $getID3 = new GetID3();
+        $audioMetadata = $getID3->analyze((string) $file);
+
+        $this->attributes['file_content_type'] = $audioMetadata['mimetype'];
+        $this->attributes['file_size'] = $audioMetadata['filesize'];
+        $this->attributes['description'] = $audioMetadata['comments']['comment'];
+        $this->attributes['file_metadata'] = $audioMetadata;
+
+        return $this;
+    }
+}
diff --git a/app/Entities/Soundbite.php b/app/Entities/Clip.php
similarity index 84%
rename from app/Entities/Soundbite.php
rename to app/Entities/Clip.php
index f6e85cfd6035440759fb7643cb7a30ef73d50901..550cf4039147df6ae21d3d33c225582af56d6876 100644
--- a/app/Entities/Soundbite.php
+++ b/app/Entities/Clip.php
@@ -22,7 +22,7 @@ use CodeIgniter\Entity\Entity;
  * @property int $created_by
  * @property int $updated_by
  */
-class Soundbite extends Entity
+class Clip extends Entity
 {
     /**
      * @var array<string, string>
@@ -33,7 +33,11 @@ class Soundbite extends Entity
         '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/Episode.php b/app/Entities/Episode.php
index 711e2bc733b69ad423e76c9e543c67527dc305e4..b5ca18463e8972f73cfe051d4549b5257fc27758 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -11,14 +11,14 @@ declare(strict_types=1);
 namespace App\Entities;
 
 use App\Libraries\SimpleRSSElement;
+use App\Models\ClipsModel;
 use App\Models\EpisodeCommentModel;
+use App\Models\MediaModel;
 use App\Models\PersonModel;
 use App\Models\PodcastModel;
 use App\Models\PostModel;
-use App\Models\SoundbiteModel;
 use CodeIgniter\Entity\Entity;
 use CodeIgniter\Files\File;
-use CodeIgniter\HTTP\Files\UploadedFile;
 use CodeIgniter\I18n\Time;
 use League\CommonMark\CommonMarkConverter;
 use RuntimeException;
@@ -31,30 +31,22 @@ use RuntimeException;
  * @property string $guid
  * @property string $slug
  * @property string $title
- * @property File $audio_file
- * @property string $audio_file_url
+ * @property int $audio_id
+ * @property Audio $audio
  * @property string $audio_file_analytics_url
  * @property string $audio_file_web_url
  * @property string $audio_file_opengraph_url
- * @property string $audio_file_path
- * @property double $audio_file_duration
- * @property string $audio_file_mimetype
- * @property int $audio_file_size
- * @property int $audio_file_header_size
  * @property string|null $description Holds text only description, striped of any markdown or html special characters
  * @property string $description_markdown
  * @property string $description_html
+ * @property int $cover_id
  * @property Image $cover
- * @property string|null $cover_path
- * @property string|null $cover_mimetype
- * @property File|null $transcript_file
- * @property string|null $transcript_file_url
- * @property string|null $transcript_file_path
- * @property string|null $transcript_file_remote_url
- * @property File|null $chapters_file
- * @property string|null $chapters_file_url
- * @property string|null $chapters_file_path
- * @property string|null $chapters_file_remote_url
+ * @property int|null $transcript_id
+ * @property Media|null $transcript
+ * @property string|null $transcript_remote_url
+ * @property int|null $chapters_id
+ * @property Media|null $chapters
+ * @property string|null $chapters_remote_url
  * @property string|null $parental_advisory
  * @property int $number
  * @property int $season_number
@@ -86,15 +78,15 @@ class Episode extends Entity
 
     protected string $link;
 
-    protected File $audio_file;
+    protected Audio $audio;
 
-    protected string $audio_file_url;
+    protected string $audio_url;
 
-    protected string $audio_file_analytics_url;
+    protected string $audio_analytics_url;
 
-    protected string $audio_file_web_url;
+    protected string $audio_web_url;
 
-    protected string $audio_file_opengraph_url;
+    protected string $audio_opengraph_url;
 
     protected string $embed_url;
 
@@ -102,9 +94,9 @@ class Episode extends Entity
 
     protected ?string $description = null;
 
-    protected File $transcript_file;
+    protected ?Media $transcript;
 
-    protected File $chapters_file;
+    protected ?Media $chapters;
 
     /**
      * @var Person[]|null
@@ -112,9 +104,9 @@ class Episode extends Entity
     protected ?array $persons = null;
 
     /**
-     * @var Soundbite[]|null
+     * @var Clip[]|null
      */
-    protected ?array $soundbites = null;
+    protected ?array $clips = null;
 
     /**
      * @var Post[]|null
@@ -146,19 +138,14 @@ class Episode extends Entity
         'guid' => 'string',
         'slug' => 'string',
         'title' => 'string',
-        'audio_file_path' => 'string',
-        'audio_file_duration' => 'double',
-        'audio_file_mimetype' => 'string',
-        'audio_file_size' => 'integer',
-        'audio_file_header_size' => 'integer',
+        'audio_id' => 'integer',
         'description_markdown' => 'string',
         'description_html' => 'string',
-        'cover_path' => '?string',
-        'cover_mimetype' => '?string',
-        'transcript_file_path' => '?string',
-        'transcript_file_remote_url' => '?string',
-        'chapters_file_path' => '?string',
-        'chapters_file_remote_url' => '?string',
+        'cover_id' => '?integer',
+        'transcript_id' => '?integer',
+        'transcript_remote_url' => '?string',
+        'chapters_id' => '?integer',
+        'chapters_remote_url' => '?string',
         'parental_advisory' => '?string',
         'number' => '?integer',
         'season_number' => '?integer',
@@ -199,108 +186,45 @@ class Episode extends Entity
 
     public function getCover(): Image
     {
-        if ($coverPath = $this->attributes['cover_path']) {
-            return new Image(null, $coverPath, $this->attributes['cover_mimetype'], config(
-                'Images'
-            )->podcastCoverSizes);
+        if (! $this->cover instanceof Image) {
+            $this->cover = (new MediaModel('image'))->getMediaById($this->cover_id);
         }
 
-        return $this->getPodcast()
-            ->cover;
+        return $this->cover;
     }
 
-    /**
-     * Saves an audio file
-     */
-    public function setAudioFile(UploadedFile | File $audioFile): static
-    {
-        helper(['media', 'id3']);
-
-        $audioMetadata = get_file_tags($audioFile);
-
-        $this->attributes['audio_file_path'] = save_media(
-            $audioFile,
-            'podcasts/' . $this->getPodcast()->handle,
-            $this->attributes['slug'],
-        );
-        $this->attributes['audio_file_duration'] =
-            $audioMetadata['playtime_seconds'];
-        $this->attributes['audio_file_mimetype'] = $audioMetadata['mime_type'];
-        $this->attributes['audio_file_size'] = $audioMetadata['filesize'];
-        $this->attributes['audio_file_header_size'] =
-            $audioMetadata['avdataoffset'];
-
-        return $this;
-    }
-
-    /**
-     * Saves an episode transcript file
-     */
-    public function setTranscriptFile(UploadedFile | File $transcriptFile): static
+    public function getAudio(): Audio
     {
-        helper('media');
-
-        $this->attributes['transcript_file_path'] = save_media(
-            $transcriptFile,
-            'podcasts/' . $this->getPodcast()
-                ->handle,
-            $this->attributes['slug'] . '-transcript',
-        );
-
-        return $this;
-    }
-
-    /**
-     * Saves an episode chapters file
-     */
-    public function setChaptersFile(UploadedFile | File $chaptersFile): static
-    {
-        helper('media');
-
-        $this->attributes['chapters_file_path'] = save_media(
-            $chaptersFile,
-            'podcasts/' . $this->getPodcast()
-                ->handle,
-            $this->attributes['slug'] . '-chapters',
-        );
-
-        return $this;
-    }
-
-    public function getAudioFile(): File
-    {
-        helper('media');
+        if (! $this->audio) {
+            $this->audio = (new MediaModel('audio'))->getMediaById($this->audio_id);
+        }
 
-        return new File(media_path($this->audio_file_path));
+        return $this->audio;
     }
 
-    public function getTranscriptFile(): ?File
+    public function getTranscript(): ?Media
     {
-        if ($this->attributes['transcript_file_path']) {
-            helper('media');
-
-            return new File(media_path($this->attributes['transcript_file_path']));
+        if ($this->transcript_id !== null && $this->transcript === null) {
+            $this->transcript = (new MediaModel('document'))->getMediaById($this->transcript_id);
         }
 
-        return null;
+        return $this->transcript;
     }
 
-    public function getChaptersFile(): ?File
+    public function getChaptersFile(): ?Media
     {
-        if ($this->attributes['chapters_file_path']) {
-            helper('media');
-
-            return new File(media_path($this->attributes['chapters_file_path']));
+        if ($this->chapters_id !== null && $this->chapters === null) {
+            $this->chapters = (new MediaModel('document'))->getMediaById($this->chapters_id);
         }
 
-        return null;
+        return $this->chapters;
     }
 
     public function getAudioFileUrl(): string
     {
         helper('media');
 
-        return media_base_url($this->audio_file_path);
+        return media_base_url($this->audio->file_path);
     }
 
     public function getAudioFileAnalyticsUrl(): string
@@ -308,15 +232,15 @@ class Episode extends Entity
         helper('analytics');
 
         // remove 'podcasts/' from audio file path
-        $strippedAudioFilePath = substr($this->audio_file_path, 9);
+        $strippedAudioFilePath = substr($this->audio->file_path, 9);
 
         return generate_episode_analytics_url(
             $this->podcast_id,
             $this->id,
             $strippedAudioFilePath,
-            $this->audio_file_duration,
-            $this->audio_file_size,
-            $this->audio_file_header_size,
+            $this->audio->duration,
+            $this->audio->file_size,
+            $this->audio->header_size,
             $this->published_at,
         );
     }
@@ -332,28 +256,26 @@ class Episode extends Entity
     }
 
     /**
-     * Gets transcript url from transcript file uri if it exists or returns the transcript_file_remote_url which can be
-     * null.
+     * Gets transcript url from transcript file uri if it exists or returns the transcript_remote_url which can be null.
      */
-    public function getTranscriptFileUrl(): ?string
+    public function getTranscriptUrl(): ?string
     {
-        if ($this->attributes['transcript_file_path']) {
-            return media_base_url($this->attributes['transcript_file_path']);
+        if ($this->transcript !== null) {
+            return $this->transcript->url;
         }
-        return $this->attributes['transcript_file_remote_url'];
+        return $this->transcript_remote_url;
     }
 
     /**
-     * Gets chapters file url from chapters file uri if it exists or returns the chapters_file_remote_url which can be
-     * null.
+     * Gets chapters file url from chapters file uri if it exists or returns the chapters_remote_url which can be null.
      */
     public function getChaptersFileUrl(): ?string
     {
-        if ($this->chapters_file_path) {
-            return media_base_url($this->chapters_file_path);
+        if ($this->chapters) {
+            return $this->chapters->url;
         }
 
-        return $this->chapters_file_remote_url;
+        return $this->chapters_remote_url;
     }
 
     /**
@@ -375,21 +297,21 @@ class Episode extends Entity
     }
 
     /**
-     * Returns the episode’s soundbites
+     * Returns the episode’s clips
      *
-     * @return Soundbite[]
+     * @return Clip[]
      */
-    public function getSoundbites(): array
+    public function getClips(): array
     {
         if ($this->id === null) {
-            throw new RuntimeException('Episode must be created before getting soundbites.');
+            throw new RuntimeException('Episode must be created before getting clips.');
         }
 
-        if ($this->soundbites === null) {
-            $this->soundbites = (new SoundbiteModel())->getEpisodeSoundbites($this->getPodcast() ->id, $this->id);
+        if ($this->clips === null) {
+            $this->clips = (new ClipsModel())->getEpisodeClips($this->getPodcast() ->id, $this->id);
         }
 
-        return $this->soundbites;
+        return $this->clips;
     }
 
     /**
diff --git a/app/Entities/Image.php b/app/Entities/Image.php
index be46133fb67fa1954b8b67b5971743a4dfbdee39..758e2fa4c55b37ef7ed354fbe140fad3ebd7a839 100644
--- a/app/Entities/Image.php
+++ b/app/Entities/Image.php
@@ -10,176 +10,68 @@ declare(strict_types=1);
 
 namespace App\Entities;
 
-use CodeIgniter\Entity\Entity;
 use CodeIgniter\Files\File;
-use Config\Images;
-use RuntimeException;
 
-/**
- * @property File|null $file
- * @property string $dirname
- * @property string $filename
- * @property string $extension
- * @property string $mimetype
- * @property string $path
- * @property string $url
- */
-class Image extends Entity
+class Image extends Media
 {
-    protected Images $config;
-
-    protected File $file;
-
-    protected string $dirname;
-
-    protected string $filename;
-
-    protected string $extension;
-
-    protected string $mimetype;
+    protected string $type = 'image';
 
     /**
-     * @var array<string, array<string, int|string>>
+     * @param array<string, mixed>|null $data
      */
-    protected array $sizes = [];
-
-    /**
-     * @param array<string, array<string, int|string>> $sizes
-     * @param File $file
-     */
-    public function __construct(?File $file, string $path = '', string $mimetype = '', array $sizes = [])
+    public function __construct(array $data = null)
     {
-        if ($file === null && $path === '') {
-            throw new RuntimeException('File or path must be set to create an Image.');
-        }
-
-        $dirname = '';
-        $filename = '';
-        $extension = '';
+        parent::__construct($data);
 
-        if ($file !== null) {
-            $dirname = $file->getPath();
-            $filename = $file->getBasename();
-            $extension = $file->getExtension();
-            $mimetype = $file->getMimeType();
+        if ($this->file_path && $this->file_metadata) {
+            $this->sizes = $this->file_metadata['sizes'];
+            $this->initSizeProperties();
         }
-
-        if ($path !== '') {
-            [
-                'filename' => $filename,
-                'dirname' => $dirname,
-                'extension' => $extension,
-            ] = pathinfo($path);
-        }
-
-        if ($file === null) {
-            helper('media');
-            $file = new File(media_path($path));
-        }
-
-        $this->file = $file;
-        $this->dirname = $dirname;
-        $this->filename = $filename;
-        $this->extension = $extension;
-        $this->mimetype = $mimetype;
-        $this->sizes = $sizes;
     }
 
-    public function __get($property)
+    public function initSizeProperties(): bool
     {
-        // Convert to CamelCase for the method
-        $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $property)));
-
-        // if a get* method exists for this property,
-        // call that method to get this value.
-        // @phpstan-ignore-next-line
-        if (method_exists($this, $method)) {
-            return $this->{$method}();
-        }
-
-        $fileSuffix = '';
-        if ($lastUnderscorePosition = strrpos($property, '_')) {
-            $fileSuffix = '_' . substr($property, 0, $lastUnderscorePosition);
-        }
-
-        $path = '';
-        if ($this->dirname !== '.') {
-            $path .= $this->dirname . '/';
-        }
-        $path .= $this->filename . $fileSuffix;
+        helper('media');
 
-        $extension = '.' . $this->extension;
+        $extension = $this->file_extension;
         $mimetype = $this->mimetype;
-        if ($fileSuffix !== '') {
-            $sizeName = substr($fileSuffix, 1);
-            if (array_key_exists('extension', $this->sizes[$sizeName])) {
-                $extension = '.' . $this->sizes[$sizeName]['extension'];
+        foreach ($this->sizes as $name => $size) {
+            if (array_key_exists('extension', $size)) {
+                $extension = $size['extension'];
             }
-            if (array_key_exists('mimetype', $this->sizes[$sizeName])) {
-                $mimetype = $this->sizes[$sizeName]['mimetype'];
+            if (array_key_exists('mimetype', $size)) {
+                $mimetype = $size['mimetype'];
             }
+            $this->{$name . '_path'} = $this->file_directory . '/' . $this->file_name . '_' . $name . '.' . $extension;
+            $this->{$name . '_url'} = media_base_url($this->{$name . '_path'});
+            $this->{$name . '_mimetype'} = $mimetype;
         }
-        $path .= $extension;
 
-        if (str_ends_with($property, 'mimetype')) {
-            return $mimetype;
-        }
-
-        if (str_ends_with($property, 'url')) {
-            helper('media');
-
-            return media_base_url($path);
-        }
-
-        if (str_ends_with($property, 'path')) {
-            return $path;
-        }
-    }
-
-    public function getMimetype(): string
-    {
-        return $this->mimetype;
-    }
-
-    public function getFile(): File
-    {
-        return $this->file;
+        return true;
     }
 
-    /**
-     * @param array<string, array<string, int|string>> $sizes
-     */
-    public function saveImage(array $sizes, string $dirname, string $filename): void
+    public function setFile(File $file): self
     {
-        helper('media');
+        parent::setFile($file);
 
-        $this->dirname = $dirname;
-        $this->filename = $filename;
-        $this->sizes = $sizes;
+        $metadata = exif_read_data(media_path($this->file_path), null, true);
 
-        save_media($this->file, $this->dirname, $this->filename);
+        if ($metadata) {
+            $metadata['sizes'] = $this->sizes;
+            $this->attributes['file_size'] = $metadata['FILE']['FileSize'];
+            $this->attributes['file_metadata'] = json_encode($metadata);
+        }
 
+        // save derived sizes
         $imageService = service('image');
-
-        foreach ($sizes as $name => $size) {
+        foreach ($this->sizes as $name => $size) {
             $pathProperty = $name . '_path';
             $imageService
-                ->withFile(media_path($this->path))
+                ->withFile(media_path($this->file_path))
                 ->resize($size['width'], $size['height']);
             $imageService->save(media_path($this->{$pathProperty}));
         }
-    }
-
-    /**
-     * @param array<string, int[]> $sizes
-     */
-    public function delete(array $sizes): void
-    {
-        helper('media');
 
-        foreach (array_keys($sizes) as $name) {
-            $pathProperty = $name . '_path';
-            unlink(media_path($this->{$pathProperty}));
-        }
+        return $this;
     }
 }
diff --git a/app/Entities/ImageOLD.php b/app/Entities/ImageOLD.php
new file mode 100644
index 0000000000000000000000000000000000000000..d46b29e141ff8a08ff565b34be02df11b12eb326
--- /dev/null
+++ b/app/Entities/ImageOLD.php
@@ -0,0 +1,123 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2021 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Entities;
+
+use CodeIgniter\Files\File;
+use Config\Images;
+
+class Image extends Media
+{
+    /**
+     * @var array<string, array<string, int|string>>
+     */
+    public array $sizes = [];
+
+    protected Images $config;
+
+    protected string $type = 'image';
+
+    public function __get($property)
+    {
+        if (str_ends_with($property, '_url') || str_ends_with($property, '_path') || str_ends_with(
+            $property,
+            '_mimetype'
+        )) {
+            $this->initSizeProperties();
+        }
+
+        parent::__get($property);
+    }
+
+    public function setFileMetadata(string $metadata): self
+    {
+        $this->attributes['file_metadata'] = $metadata;
+
+        $metadataArray = json_decode($metadata, true);
+        if (! array_key_exists('sizes', $metadataArray)) {
+            return $this;
+        }
+
+        $this->sizes = $metadataArray['sizes'];
+
+        return $this;
+    }
+
+    public function initSizeProperties(): bool
+    {
+        if ($this->file_path === '') {
+            return false;
+        }
+
+        if ($this->sizes === []) {
+            $this->sizes = $this->file_metadata['sizes'];
+        }
+
+        helper('media');
+
+        $extension = $this->file_extension;
+        $mimetype = $this->mimetype;
+        foreach ($this->sizes as $name => $size) {
+            if (array_key_exists('extension', $size)) {
+                $extension = $size['extension'];
+            }
+            if (array_key_exists('mimetype', $size)) {
+                $mimetype = $size['mimetype'];
+            }
+            $this->{$name . '_path'} = $this->file_directory . '/' . $this->file_name . '_' . $name . '.' . $extension;
+            $this->{$name . '_url'} = media_base_url($this->{$name . '_path'});
+            $this->{$name . '_mimetype'} = $mimetype;
+        }
+
+        return true;
+    }
+
+    public function saveInDisk(File $file, string $dirname, string $filename): void
+    {
+        // save original
+        parent::saveInDisk($file, $dirname, $filename);
+
+        $this->initSizeProperties();
+
+        // save derived sizes
+        $imageService = service('image');
+        foreach ($this->sizes as $name => $size) {
+            $pathProperty = $name . '_path';
+            $imageService
+                ->withFile(media_path($this->file_path))
+                ->resize($size['width'], $size['height']);
+            $imageService->save(media_path($this->{$pathProperty}));
+        }
+    }
+
+    public function injectFileData(File $file): void
+    {
+        $metadata = exif_read_data(media_path($this->file_path), null, true);
+
+        if ($metadata) {
+            $metadata['sizes'] = $this->sizes;
+            $this->file_size = $metadata['FILE']['FileSize'];
+            $this->file_metadata = $metadata;
+        }
+    }
+
+    /**
+     * @param array<string, int[]> $sizes
+     */
+    public function delete(array $sizes): void
+    {
+        helper('media');
+
+        foreach (array_keys($sizes) as $name) {
+            $pathProperty = $name . '_path';
+            unlink(media_path($this->{$pathProperty}));
+        }
+    }
+}
diff --git a/app/Entities/Media.php b/app/Entities/Media.php
new file mode 100644
index 0000000000000000000000000000000000000000..b979edbc9c458d12ee167420698197a198a1b205
--- /dev/null
+++ b/app/Entities/Media.php
@@ -0,0 +1,95 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2021 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Entities;
+
+use CodeIgniter\Entity\Entity;
+use CodeIgniter\Files\File;
+
+/**
+ * @property int $id
+ * @property string $file_path
+ * @property string $file_directory
+ * @property string $file_extension
+ * @property string $file_name
+ * @property int $file_size
+ * @property string $file_content_type
+ * @property array $file_metadata
+ * @property 'image'|'audio'|'video'|'document' $type
+ * @property string $description
+ * @property string|null $language_code
+ * @property int $uploaded_by
+ * @property int $updated_by
+ */
+class Media extends Entity
+{
+    protected File $file;
+
+    protected string $type = 'document';
+
+    /**
+     * @var string[]
+     */
+    protected $dates = ['uploaded_at', 'updated_at', 'deleted_at'];
+
+    /**
+     * @var array<string, string>
+     */
+    protected $casts = [
+        'id' => 'integer',
+        'file_path' => 'string',
+        'file_size' => 'int',
+        'file_content_type' => 'string',
+        'file_metadata' => 'json-array',
+        'type' => 'string',
+        'description' => 'string',
+        'language_code' => '?string',
+        'uploaded_by' => 'integer',
+        'updated_by' => 'integer',
+    ];
+
+    /**
+     * @param array<string, mixed>|null $data
+     */
+    public function __construct(array $data = null)
+    {
+        parent::__construct($data);
+
+        if ($this->file_path) {
+            [
+                'filename' => $filename,
+                'dirname' => $dirname,
+                'extension' => $extension,
+            ] = pathinfo($this->file_path);
+
+            $this->file_name = $filename;
+            $this->file_directory = $dirname;
+            $this->file_extension = $extension;
+        }
+    }
+
+    public function setFile(File $file): self
+    {
+        helper('media');
+
+        $this->attributes['file_content_type'] = $file->getMimeType();
+        $this->attributes['file_metadata'] = json_encode(lstat((string) $file));
+        $this->attributes['file_path'] = save_media(
+            $file,
+            $this->attributes['file_directory'],
+            $this->attributes['file_name']
+        );
+        if ($filesize = filesize(media_path($this->file_path))) {
+            $this->attributes['file_size'] = $filesize;
+        }
+
+        return $this;
+    }
+}
diff --git a/app/Entities/MediaOLD.php b/app/Entities/MediaOLD.php
new file mode 100644
index 0000000000000000000000000000000000000000..d585ad853a16c6475cf265ee55f26bf6e8aa7edb
--- /dev/null
+++ b/app/Entities/MediaOLD.php
@@ -0,0 +1,93 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2021 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Entities;
+
+use CodeIgniter\Entity\Entity;
+use CodeIgniter\Files\File;
+
+/**
+ * @property int $id
+ * @property string $file_path
+ * @property string $file_directory
+ * @property string $file_extension
+ * @property string $file_name
+ * @property int $file_size
+ * @property string $file_content_type
+ * @property array $file_metadata
+ * @property 'image'|'audio'|'video'|'document' $type
+ * @property string $description
+ * @property string|null $language_code
+ * @property int $uploaded_by
+ * @property int $updated_by
+ */
+class Media extends Entity
+{
+    protected File $file;
+
+    /**
+     * @var string[]
+     */
+    protected $dates = ['uploaded_at', 'updated_at', 'deleted_at'];
+
+    /**
+     * @var array<string, string>
+     */
+    protected $casts = [
+        'id' => 'integer',
+        'file_path' => 'string',
+        'file_size' => 'string',
+        'file_content_type' => 'string',
+        'file_metadata' => 'json-array',
+        'type' => 'string',
+        'description' => 'string',
+        'language_code' => '?string',
+        'uploaded_by' => 'integer',
+        'updated_by' => 'integer',
+    ];
+
+    public function setFilePath(string $path): self
+    {
+        $this->attributes['file_path'] = $path;
+
+        [
+            'filename' => $filename,
+            'dirname' => $dirname,
+            'extension' => $extension,
+        ] = pathinfo($path);
+
+        $this->file_name = $filename;
+        $this->file_directory = $dirname;
+        $this->file_extension = $extension;
+
+        return $this;
+    }
+
+    public function saveInDisk(File $file, string $dirname, string $filename): void
+    {
+        helper('media');
+
+        $this->file_content_type = $file->getMimeType();
+
+        $filePath = save_media($file, $dirname, $filename);
+
+        $this->file_path = $filePath;
+    }
+
+    public function injectFileData(File $file): void
+    {
+        $this->file_content_type = $file->getMimeType();
+        $this->type = 'document';
+
+        if ($filesize = filesize(media_path($this->file_path))) {
+            $this->file_size = $filesize;
+        }
+    }
+}
diff --git a/app/Entities/Person.php b/app/Entities/Person.php
index 3204053a7d49f75862521c6bc90df798a4b08577..10e9fd2273ce56dba44dc993db5fae53f695817c 100644
--- a/app/Entities/Person.php
+++ b/app/Entities/Person.php
@@ -19,20 +19,15 @@ use RuntimeException;
  * @property string $full_name
  * @property string $unique_name
  * @property string|null $information_url
+ * @property int $avatar_id
  * @property Image $avatar
- * @property string $avatar_path
- * @property string $avatar_mimetype
  * @property int $created_by
  * @property int $updated_by
  * @property object[]|null $roles
  */
 class Person extends Entity
 {
-    protected Image $avatar;
-
-    protected ?int $podcast_id = null;
-
-    protected ?int $episode_id = null;
+    protected ?Image $avatar = null;
 
     /**
      * @var object[]|null
@@ -47,8 +42,7 @@ class Person extends Entity
         'full_name' => 'string',
         'unique_name' => 'string',
         'information_url' => '?string',
-        'avatar_path' => '?string',
-        'avatar_mimetype' => '?string',
+        'avatar_id' => '?int',
         'podcast_id' => '?integer',
         'episode_id' => '?integer',
         'created_by' => 'integer',
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 16d00f646fb7578bb493ab31bceb4c923e570f6a..1663a6b32fcee5b3ecae96671a7ed21c0d771ed8 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -13,6 +13,7 @@ namespace App\Entities;
 use App\Libraries\SimpleRSSElement;
 use App\Models\CategoryModel;
 use App\Models\EpisodeModel;
+use App\Models\MediaModel;
 use App\Models\PersonModel;
 use App\Models\PlatformModel;
 use App\Models\UserModel;
@@ -34,12 +35,10 @@ use RuntimeException;
  * @property string|null $description Holds text only description, striped of any markdown or html special characters
  * @property string $description_markdown
  * @property  string $description_html
+ * @property int $cover_id
  * @property Image $cover
- * @property string $cover_path
- * @property string $cover_mimetype
+ * @property int|null $banner_id
  * @property Image|null $banner
- * @property string|null $banner_path
- * @property string|null $banner_mimetype
  * @property string $language_code
  * @property int $category_id
  * @property Category|null $category
@@ -87,9 +86,9 @@ class Podcast extends Entity
 
     protected ?Actor $actor = null;
 
-    protected Image $cover;
+    protected ?Image $cover = null;
 
-    protected ?Image $banner;
+    protected ?Image $banner = null;
 
     protected ?string $description = null;
 
@@ -150,10 +149,8 @@ class Podcast extends Entity
         'title' => 'string',
         'description_markdown' => 'string',
         'description_html' => 'string',
-        'cover_path' => 'string',
-        'cover_mimetype' => 'string',
-        'banner_path' => '?string',
-        'banner_mimetype' => '?string',
+        'cover_id' => 'int',
+        'banner_id' => '?int',
         'language_code' => 'string',
         'category_id' => 'integer',
         'parental_advisory' => '?string',
@@ -195,66 +192,36 @@ class Podcast extends Entity
         return $this->actor;
     }
 
-    /**
-     * Saves a podcast cover to the corresponding podcast folder in `public/media/podcast_name/`
-     */
-    public function setCover(Image $cover): static
-    {
-        // Save image
-        $cover->saveImage(config('Images')->podcastCoverSizes, 'podcasts/' . $this->attributes['handle'], 'cover');
-
-        $this->attributes['cover_path'] = $cover->path;
-        $this->attributes['cover_mimetype'] = $cover->mimetype;
-
-        return $this;
-    }
-
     public function getCover(): Image
     {
-        return new Image(null, $this->cover_path, $this->cover_mimetype, config('Images')->podcastCoverSizes);
-    }
-
-    /**
-     * Saves a podcast cover to the corresponding podcast folder in `public/media/podcast_name/`
-     */
-    public function setBanner(?Image $banner): static
-    {
-        if ($banner === null) {
-            $this->attributes['banner_path'] = null;
-            $this->attributes['banner_mimetype'] = null;
-
-            return $this;
+        if (! $this->cover instanceof Image) {
+            $this->cover = (new MediaModel('image'))->getMediaById($this->cover_id);
         }
 
-        // Save image
-        $banner->saveImage(
-            config('Images')
-                ->podcastBannerSizes,
-            'podcasts/' . $this->attributes['handle'],
-            'banner'
-        );
-
-        $this->attributes['banner_path'] = $banner->path;
-        $this->attributes['banner_mimetype'] = $banner->mimetype;
-
-        return $this;
+        return $this->cover;
     }
 
     public function getBanner(): Image
     {
-        if ($this->attributes['banner_path'] === null) {
-            return new Image(
-                null,
-                config('Images')
+        if ($this->banner_id === null) {
+            return new Image([
+                'file_path' => config('Images')
                     ->podcastBannerDefaultPath,
-                config('Images')
+                'file_mimetype' => config('Images')
                     ->podcastBannerDefaultMimeType,
-                config('Images')
-                    ->podcastBannerSizes
-            );
+                'file_size' => 0,
+                'file_metadata' => [
+                    'sizes' => config('Images')
+                        ->podcastBannerSizes,
+                ],
+            ]);
+        }
+
+        if (! $this->banner instanceof Image) {
+            $this->banner = (new MediaModel('image'))->getMediaById($this->banner_id);
         }
 
-        return new Image(null, $this->banner_path, $this->banner_mimetype, config('Images') ->podcastBannerSizes);
+        return $this->banner;
     }
 
     public function getLink(): string
diff --git a/app/Helpers/id3_helper.php b/app/Helpers/id3_helper.php
index c975f54b8af05c65a1a082482da06db365b73eac..abf89a61437b991dbe4b2d420af1f497e79ed2d2 100644
--- a/app/Helpers/id3_helper.php
+++ b/app/Helpers/id3_helper.php
@@ -10,29 +10,8 @@ declare(strict_types=1);
 
 use App\Entities\Episode;
 use CodeIgniter\Files\File;
-use JamesHeinrich\GetID3\GetID3;
 use JamesHeinrich\GetID3\WriteTags;
 
-if (! function_exists('get_file_tags')) {
-    /**
-     * Gets audio file metadata and ID3 info
-     *
-     * @return array<string, string|double|int>
-     */
-    function get_file_tags(File $file): array
-    {
-        $getID3 = new GetID3();
-        $FileInfo = $getID3->analyze((string) $file);
-
-        return [
-            'filesize' => $FileInfo['filesize'],
-            'mime_type' => $FileInfo['mime_type'],
-            'avdataoffset' => $FileInfo['avdataoffset'],
-            'playtime_seconds' => $FileInfo['playtime_seconds'],
-        ];
-    }
-}
-
 if (! function_exists('write_audio_file_tags')) {
     /**
      * Write audio file metadata / ID3 tags
@@ -45,7 +24,7 @@ if (! function_exists('write_audio_file_tags')) {
 
         // Initialize getID3 tag-writing module
         $tagwriter = new WriteTags();
-        $tagwriter->filename = media_path($episode->audio_file_path);
+        $tagwriter->filename = media_path($episode->audio->file_path);
 
         // set various options (optional)
         $tagwriter->tagformats = ['id3v2.4'];
diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php
index adb7d3bd5de2d95957accb60c75ae0882abf7c5f..fbb845d8f82f47c6fd88ae46f503e92116a30d15 100644
--- a/app/Helpers/rss_helper.php
+++ b/app/Helpers/rss_helper.php
@@ -211,8 +211,8 @@ if (! function_exists('get_rss_feed')) {
                         ? ''
                         : '?_from=' . urlencode($serviceSlug)),
             );
-            $enclosure->addAttribute('length', (string) $episode->audio_file_size);
-            $enclosure->addAttribute('type', $episode->audio_file_mimetype);
+            $enclosure->addAttribute('length', (string) $episode->audio->file_size);
+            $enclosure->addAttribute('type', $episode->audio->file_content_type);
 
             $item->addChild('guid', $episode->guid);
             $item->addChild('pubDate', $episode->published_at->format(DATE_RFC1123));
@@ -230,7 +230,7 @@ if (! function_exists('get_rss_feed')) {
                 }
             }
             $item->addChildWithCDATA('description', $episode->getDescriptionHtml($serviceSlug));
-            $item->addChild('duration', (string) $episode->audio_file_duration, $itunesNamespace);
+            $item->addChild('duration', (string) $episode->audio->duration, $itunesNamespace);
             $item->addChild('link', $episode->link);
             $episodeItunesImage = $item->addChild('image', null, $itunesNamespace);
             $episodeItunesImage->addAttribute('href', $episode->cover->feed_url);
@@ -255,7 +255,7 @@ if (! function_exists('get_rss_feed')) {
             $comments->addAttribute('uri', url_to('episode-comments', $podcast->handle, $episode->slug));
             $comments->addAttribute('contentType', 'application/podcast-activity+json');
 
-            if ($episode->transcript_file_url) {
+            if ($episode->transcript->file_url) {
                 $transcriptElement = $item->addChild('transcript', null, $podcastNamespace);
                 $transcriptElement->addAttribute('url', $episode->transcript_file_url);
                 $transcriptElement->addAttribute(
@@ -267,16 +267,17 @@ if (! function_exists('get_rss_feed')) {
                 $transcriptElement->addAttribute('language', $podcast->language_code);
             }
 
-            if ($episode->chapters_file_url) {
+            if ($episode->chapters->file_url) {
                 $chaptersElement = $item->addChild('chapters', null, $podcastNamespace);
                 $chaptersElement->addAttribute('url', $episode->chapters_file_url);
                 $chaptersElement->addAttribute('type', 'application/json+chapters');
             }
 
-            foreach ($episode->soundbites as $soundbite) {
-                $soundbiteElement = $item->addChild('soundbite', $soundbite->label, $podcastNamespace);
-                $soundbiteElement->addAttribute('start_time', (string) $soundbite->start_time);
-                $soundbiteElement->addAttribute('duration', (string) $soundbite->duration);
+            foreach ($episode->clip as $clip) {
+                // 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);
             }
 
             foreach ($episode->persons as $person) {
diff --git a/app/Helpers/seo_helper.php b/app/Helpers/seo_helper.php
index c392d0d582a323eda6c62423d734cc04f8d2bfe7..72b0c790117f1382e5f73dfcc951ceedb80a500c 100644
--- a/app/Helpers/seo_helper.php
+++ b/app/Helpers/seo_helper.php
@@ -64,9 +64,9 @@ if (! function_exists('get_episode_metatags')) {
                 'image' => $episode->cover->feed_url,
                 'description' => $episode->description,
                 'datePublished' => $episode->published_at->format(DATE_ISO8601),
-                'timeRequired' => iso8601_duration($episode->audio_file_duration),
+                'timeRequired' => iso8601_duration($episode->audio->duration),
                 'associatedMedia' => new Thing('MediaObject', [
-                    'contentUrl' => $episode->audio_file_url,
+                    'contentUrl' => $episode->audio->file_url,
                 ]),
                 'partOfSeries' => new Thing('PodcastSeries', [
                     'name' => $episode->podcast->title,
@@ -87,7 +87,7 @@ if (! function_exists('get_episode_metatags')) {
             ->og('image:height', (string) config('Images')->podcastCoverSizes['large']['height'])
             ->og('locale', $episode->podcast->language_code)
             ->og('audio', $episode->audio_file_opengraph_url)
-            ->og('audio:type', $episode->audio_file_mimetype)
+            ->og('audio:type', $episode->audio->file_content_type)
             ->meta('article:published_time', $episode->published_at->format(DATE_ISO8601))
             ->meta('article:modified_time', $episode->updated_at->format(DATE_ISO8601))
             ->twitter('audio:partner', $episode->podcast->publisher ?? '')
diff --git a/app/Libraries/MediaClipper/VideoClip.php b/app/Libraries/MediaClipper/VideoClip.php
index d1f11de5b97360fbb4bd4f4a5cedaa8a14621169..647e8545c4c0b0953e4432185ad5633f0c9e122f 100644
--- a/app/Libraries/MediaClipper/VideoClip.php
+++ b/app/Libraries/MediaClipper/VideoClip.php
@@ -79,10 +79,10 @@ class VideoClip
 
         helper(['media']);
 
-        $this->audioInput = media_path($this->episode->audio_file_path);
+        $this->audioInput = media_path($this->episode->audio->file_path);
         $this->episodeCoverPath = media_path($this->episode->cover->path);
-        if ($this->episode->transcript_file_path !== null) {
-            $this->subtitlesInput = media_path($this->episode->transcript_file_path);
+        if ($this->episode->transcript !== null) {
+            $this->subtitlesInput = media_path($this->episode->transcript->file_path);
         }
 
         $podcastFolder = media_path("podcasts/{$this->episode->podcast->handle}");
@@ -167,7 +167,6 @@ class VideoClip
             "{$this->videoClipOutput}",
         ];
 
-        // dd(implode(' ', $videoClipCmd));
         return implode(' ', $videoClipCmd);
     }
 
diff --git a/app/Libraries/PodcastEpisode.php b/app/Libraries/PodcastEpisode.php
index 11bc23eb46e1d210519e10308a2ed642e9f48932..5d7aece560d4676b131be8b4ac9a2d4f82382ad6 100644
--- a/app/Libraries/PodcastEpisode.php
+++ b/app/Libraries/PodcastEpisode.php
@@ -52,24 +52,24 @@ class PodcastEpisode extends ObjectType
 
         $this->image = [
             'type' => 'Image',
-            'mediaType' => $episode->cover_mimetype,
+            'mediaType' => $episode->cover->file_content_type,
             'url' => $episode->cover->feed_url,
         ];
 
         // add audio file
         $this->audio = [
-            'id' => $episode->audio_file_url,
+            'id' => $episode->audio->file_url,
             'type' => 'Audio',
             'name' => $episode->title,
-            'size' => $episode->audio_file_size,
-            'duration' => $episode->audio_file_duration,
+            'size' => $episode->audio->file_size,
+            'duration' => $episode->audio->duration,
             'url' => [
-                'href' => $episode->audio_file_url,
+                'href' => $episode->audio->file_url,
                 'type' => 'Link',
-                'mediaType' => $episode->audio_file_mimetype,
+                'mediaType' => $episode->audio->file_content_type,
             ],
-            'transcript' => $episode->transcript_file_url,
-            'chapters' => $episode->chapters_file_url,
+            'transcript' => $episode->transcript->file_url,
+            'chapters' => $episode->chapters->file_url,
         ];
 
         $this->comments = url_to('episode-comments', $episode->podcast->handle, $episode->slug);
diff --git a/app/Models/SoundbiteModel.php b/app/Models/ClipModel.php
similarity index 83%
rename from app/Models/SoundbiteModel.php
rename to app/Models/ClipModel.php
index e17140b0a76cab994549cb74c5b7d1dbde02c13e..6a7b2d8cc5f9588f4560e1f2c97b63325bc5ac80 100644
--- a/app/Models/SoundbiteModel.php
+++ b/app/Models/ClipModel.php
@@ -12,16 +12,16 @@ declare(strict_types=1);
 
 namespace App\Models;
 
-use App\Entities\Soundbite;
+use App\Entities\Clip;
 use CodeIgniter\Database\BaseResult;
 use CodeIgniter\Model;
 
-class SoundbiteModel extends Model
+class ClipsModel extends Model
 {
     /**
      * @var string
      */
-    protected $table = 'soundbites';
+    protected $table = 'clips';
 
     /**
      * @var string
@@ -35,6 +35,7 @@ class SoundbiteModel extends Model
         'podcast_id',
         'episode_id',
         'label',
+        'type',
         'start_time',
         'duration',
         'created_by',
@@ -44,7 +45,7 @@ class SoundbiteModel extends Model
     /**
      * @var string
      */
-    protected $returnType = Soundbite::class;
+    protected $returnType = Clip::class;
 
     /**
      * @var bool
@@ -71,23 +72,23 @@ class SoundbiteModel extends Model
      */
     protected $beforeDelete = ['clearCache'];
 
-    public function deleteSoundbite(int $podcastId, int $episodeId, int $soundbiteId): BaseResult | bool
+    public function deleteClip(int $podcastId, int $episodeId, int $clipId): BaseResult | bool
     {
         return $this->delete([
             'podcast_id' => $podcastId,
             'episode_id' => $episodeId,
-            'id' => $soundbiteId,
+            'id' => $clipId,
         ]);
     }
 
     /**
-     * Gets all soundbites for an episode
+     * Gets all clips for an episode
      *
-     * @return Soundbite[]
+     * @return Clip[]
      */
-    public function getEpisodeSoundbites(int $podcastId, int $episodeId): array
+    public function getEpisodeClips(int $podcastId, int $episodeId): array
     {
-        $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_soundbites";
+        $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_clips";
         if (! ($found = cache($cacheName))) {
             $found = $this->where([
                 'episode_id' => $episodeId,
@@ -114,7 +115,7 @@ class SoundbiteModel extends Model
         );
 
         cache()
-            ->delete("podcast#{$episode->podcast_id}_episode#{$episode->id}_soundbites");
+            ->delete("podcast#{$episode->podcast_id}_episode#{$episode->id}_clips");
 
         // delete cache for rss feed
         cache()
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index c73886f32f2052ef11dc280ead27c0fd4348d794..043e4fb697238f11300723bca3160727d587691f 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -68,18 +68,13 @@ class EpisodeModel extends Model
         'guid',
         'title',
         'slug',
-        'audio_file_path',
-        'audio_file_duration',
-        'audio_file_mimetype',
-        'audio_file_size',
-        'audio_file_header_size',
+        'audio_file_id',
         'description_markdown',
         'description_html',
-        'cover_path',
-        'cover_mimetype',
-        'transcript_file_path',
+        'cover_id',
+        'transcript_file_id',
         'transcript_file_remote_url',
-        'chapters_file_path',
+        'chapters_file_id',
         'chapters_file_remote_url',
         'parental_advisory',
         'number',
@@ -119,7 +114,7 @@ class EpisodeModel extends Model
         'podcast_id' => 'required',
         'title' => 'required',
         'slug' => 'required|regex_match[/^[a-zA-Z0-9\-]{1,191}$/]',
-        'audio_file_path' => 'required',
+        'audio_file_id' => 'required',
         'description_markdown' => 'required',
         'number' => 'is_natural_no_zero|permit_empty',
         'season_number' => 'is_natural_no_zero|permit_empty',
diff --git a/app/Models/MediaModel.php b/app/Models/MediaModel.php
new file mode 100644
index 0000000000000000000000000000000000000000..6d760524daadf65c7593073117504d7656291412
--- /dev/null
+++ b/app/Models/MediaModel.php
@@ -0,0 +1,109 @@
+<?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\Models;
+
+use App\Entities\Audio;
+use App\Entities\Image;
+use App\Entities\Media;
+use CodeIgniter\Database\ConnectionInterface;
+use CodeIgniter\Model;
+use CodeIgniter\Validation\ValidationInterface;
+
+class MediaModel extends Model
+{
+    /**
+     * @var string
+     */
+    protected $table = 'media';
+
+    /**
+     * @var string
+     */
+    protected $returnType = Media::class;
+
+    /**
+     * @var string[]
+     */
+    protected $allowedFields = [
+        'id',
+        'file_path',
+        'file_size',
+        'file_content_type',
+        'file_metadata',
+        'type',
+        'description',
+        'language_code',
+        'uploaded_by',
+        'updated_by',
+    ];
+
+    /**
+     * Model constructor.
+     *
+     * @param ConnectionInterface|null $db         DB Connection
+     * @param ValidationInterface|null $validation Validation
+     */
+    public function __construct(
+        protected string $fileType,
+        ConnectionInterface &$db = null,
+        ValidationInterface $validation = null
+    ) {
+        switch ($fileType) {
+            case 'audio':
+                $this->returnType = Audio::class;
+                break;
+            case 'image':
+                $this->returnType = Image::class;
+                break;
+            default:
+                // do nothing, keep Media class as default
+                break;
+        }
+
+        parent::__construct($db, $validation);
+    }
+
+    /**
+     * @return Media|Image|Audio
+     */
+    public function getMediaById(int $mediaId): object
+    {
+        $cacheName = "media#{$mediaId}";
+        if (! ($found = cache($cacheName))) {
+            $builder = $this->where([
+                'id' => $mediaId,
+            ]);
+
+            $result = $builder->first();
+            $mediaClass = $this->returnType;
+            $found = new $mediaClass($result->toArray(false, true));
+
+            cache()
+                ->save($cacheName, $found, DECADE);
+        }
+
+        return $found;
+    }
+
+    /**
+     * @param Media|Image|Audio $media
+     */
+    public function saveMedia(object $media): int | false
+    {
+        // insert record in database
+        if (! $mediaId = $this->insert($media, true)) {
+            return false;
+        }
+
+        // @phpstan-ignore-next-line
+        return $mediaId;
+    }
+}
diff --git a/app/Models/MediaModelOLD.php b/app/Models/MediaModelOLD.php
new file mode 100644
index 0000000000000000000000000000000000000000..fcdc566006a3a3576c2f2d5a997aa2bc5d3dd04e
--- /dev/null
+++ b/app/Models/MediaModelOLD.php
@@ -0,0 +1,112 @@
+<?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\Models;
+
+use App\Entities\Audio;
+use App\Entities\Image;
+use App\Entities\Media;
+use CodeIgniter\Database\ConnectionInterface;
+use CodeIgniter\Model;
+use CodeIgniter\Validation\ValidationInterface;
+
+class MediaModel extends Model
+{
+    /**
+     * @var string
+     */
+    protected $table = 'media';
+
+    /**
+     * @var string
+     */
+    protected $returnType = Media::class;
+
+    /**
+     * @var string[]
+     */
+    protected $allowedFields = [
+        'id',
+        'file_path',
+        'file_size',
+        'file_content_type',
+        'file_metadata',
+        'type',
+        'description',
+        'language_code',
+        'uploaded_by',
+        'updated_by',
+    ];
+
+    /**
+     * Model constructor.
+     *
+     * @param ConnectionInterface|null $db         DB Connection
+     * @param ValidationInterface|null $validation Validation
+     */
+    public function __construct(
+        protected string $fileType,
+        ConnectionInterface &$db = null,
+        ValidationInterface $validation = null
+    ) {
+        switch ($fileType) {
+            case 'audio':
+                $this->returnType = Audio::class;
+                break;
+            case 'image':
+                $this->returnType = Image::class;
+                break;
+            default:
+                // do nothing, keep Media class as default
+                break;
+        }
+
+        parent::__construct($db, $validation);
+    }
+
+    /**
+     * @return Media|Image|Audio
+     */
+    public function getMediaById(int $mediaId): object
+    {
+        $cacheName = "media#{$mediaId}";
+        if (! ($found = cache($cacheName))) {
+            $builder = $this->where([
+                'id' => $mediaId,
+            ]);
+
+            $found = $builder->first();
+
+            cache()
+                ->save($cacheName, $found, DECADE);
+        }
+
+        return $found;
+    }
+
+    /**
+     * @param Media|Image $media
+     */
+    public function saveMedia(object $media): int | false
+    {
+        // insert record in database
+        if (! $mediaId = $this->insert($media, true)) {
+            return false;
+        }
+
+        // @phpstan-ignore-next-line
+        return $mediaId;
+    }
+
+    public function deleteFile(int $mediaId): void
+    {
+        // TODO: get file, delete it from disk & from database
+    }
+}
diff --git a/app/Models/PersonModel.php b/app/Models/PersonModel.php
index 77cc85c2fe64bcb81ac05fbe3c4a6ef0208be911..9e5688825e8065bb8524271734ef86041c8fc5e5 100644
--- a/app/Models/PersonModel.php
+++ b/app/Models/PersonModel.php
@@ -35,8 +35,7 @@ class PersonModel extends Model
         'full_name',
         'unique_name',
         'information_url',
-        'avatar_path',
-        'avatar_mimetype',
+        'avatar_id',
         'created_by',
         'updated_by',
     ];
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index aef6207c39cff77a54381f35717f35a6a78b2891..02a913bb2448c2049a757113a8da3876706a8bad 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -40,10 +40,8 @@ class PodcastModel extends Model
         'description_html',
         'episode_description_footer_markdown',
         'episode_description_footer_html',
-        'cover_path',
-        'cover_mimetype',
-        'banner_path',
-        'banner_mimetype',
+        'cover_id',
+        'banner_id',
         'language_code',
         'category_id',
         'parental_advisory',
@@ -92,7 +90,7 @@ class PodcastModel extends Model
         'handle' =>
             'required|regex_match[/^[a-zA-Z0-9\_]{1,32}$/]|is_unique[podcasts.handle,id,{id}]',
         'description_markdown' => 'required',
-        'cover_path' => 'required',
+        'cover_id' => 'required',
         'language_code' => 'required',
         'category_id' => 'required',
         'owner_email' => 'required|valid_email',
@@ -460,7 +458,7 @@ class PodcastModel extends Model
 
             if ($podcastActor) {
                 $podcastActor->avatar_image_url = $podcast->cover->thumbnail_url;
-                $podcastActor->avatar_image_mimetype = $podcast->cover_mimetype;
+                $podcastActor->avatar_image_mimetype = $podcast->cover->thumbnail_mimetype;
 
                 (new ActorModel())->update($podcast->actor_id, $podcastActor);
             }
diff --git a/app/Resources/js/modules/EpisodesMap.ts b/app/Resources/js/modules/EpisodesMap.ts
index 23ae543bcded8117acc67b95ef96ec8add5ccf18..a90e558532fada1aaafda88998d22bf3f5beae45 100644
--- a/app/Resources/js/modules/EpisodesMap.ts
+++ b/app/Resources/js/modules/EpisodesMap.ts
@@ -47,7 +47,7 @@ const drawEpisodesMap = async (mapDivId: string, dataUrl: string) => {
         data[i].longitude,
       ]).bindPopup(
         '<div class="flex min-w-max w-full gap-x-2"><img src="' +
-          data[i].cover_path +
+          data[i].cover_url +
           '" alt="' +
           data[i].episode_title +
           '" class="rounded w-16 h-16" /><div class="flex flex-col flex-1"><h2 class="leading-tight text-sm w-56 line-clamp-2 font-bold"><a href="' +
diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php
index cd33cc4b213deb35083f1dcd4f017f9f6d59685d..b0847e1c9d1742f620638f791a875aca82eb9fdf 100644
--- a/modules/Admin/Controllers/EpisodeController.php
+++ b/modules/Admin/Controllers/EpisodeController.php
@@ -14,13 +14,15 @@ use App\Entities\Episode;
 use App\Entities\EpisodeComment;
 use App\Entities\Image;
 use App\Entities\Location;
+use App\Entities\Media;
 use App\Entities\Podcast;
 use App\Entities\Post;
+use App\Models\ClipsModel;
 use App\Models\EpisodeCommentModel;
 use App\Models\EpisodeModel;
+use App\Models\MediaModel;
 use App\Models\PodcastModel;
 use App\Models\PostModel;
-use App\Models\SoundbiteModel;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
 use CodeIgniter\I18n\Time;
@@ -156,9 +158,30 @@ class EpisodeController extends BaseController
             'published_at' => null,
         ]);
 
+        $db = db_connect();
+        $db->transStart();
+
         $coverFile = $this->request->getFile('cover');
         if ($coverFile !== null && $coverFile->isValid()) {
-            $newEpisode->cover = new Image($coverFile);
+            $cover = new Image([
+                'file_name' => $newEpisode->slug,
+                'file_directory' => 'podcasts/' . $this->podcast->handle,
+                'sizes' => config('Images')
+                    ->podcastBannerSizes,
+                'file' => $this->request->getFile('banner'),
+                'uploaded_by' => user_id(),
+                'updated_by' => user_id(),
+            ]);
+            $mediaModel = new MediaModel('image');
+            if (! ($newCoverId = $mediaModel->saveMedia($cover))) {
+                $db->transRollback();
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $mediaModel->errors());
+            }
+
+            $newEpisode->cover_id = $newCoverId;
         }
 
         $transcriptChoice = $this->request->getPost('transcript-choice');
@@ -167,10 +190,26 @@ class EpisodeController extends BaseController
             && ($transcriptFile = $this->request->getFile('transcript_file'))
             && $transcriptFile->isValid()
         ) {
-            $newEpisode->transcript_file = $transcriptFile;
+            $transcript = new Media([
+                'file_name' => $newEpisode->slug . '-transcript',
+                'file_directory' => 'podcasts/' . $this->podcast->handle,
+                'file' => $transcriptFile,
+                'uploaded_by' => user_id(),
+                'updated_by' => user_id(),
+            ]);
+            $mediaModel = new MediaModel('image');
+            if (! ($newTranscriptId = $mediaModel->saveMedia($transcript))) {
+                $db->transRollback();
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $mediaModel->errors());
+            }
+
+            $newEpisode->transcript_id = $newTranscriptId;
         } elseif ($transcriptChoice === 'remote-url') {
-            $newEpisode->transcript_file_remote_url = $this->request->getPost(
-                'transcript_file_remote_url'
+            $newEpisode->transcript_remote_url = $this->request->getPost(
+                'transcript_remote_url'
             ) === '' ? null : $this->request->getPost('transcript_file_remote_url');
         }
 
@@ -813,11 +852,11 @@ class EpisodeController extends BaseController
         return redirect()->route('soundbites-edit', [$this->podcast->id, $this->episode->id]);
     }
 
-    public function soundbiteDelete(string $soundbiteId): RedirectResponse
+    public function soundbiteDelete(string $clipId): RedirectResponse
     {
-        (new SoundbiteModel())->deleteSoundbite($this->podcast->id, $this->episode->id, (int) $soundbiteId);
+        (new ClipsModel())->deleteClip($this->podcast->id, $this->episode->id, (int) $clipId);
 
-        return redirect()->route('soundbites-edit', [$this->podcast->id, $this->episode->id]);
+        return redirect()->route('clips-edit', [$this->podcast->id, $this->episode->id]);
     }
 
     public function embed(): string
diff --git a/modules/Admin/Controllers/PodcastController.php b/modules/Admin/Controllers/PodcastController.php
index d2cae11f7334df61660371f7981531e9c22d0fc7..e3e0162e600a8be54aa9b94fb326e0818e80614d 100644
--- a/modules/Admin/Controllers/PodcastController.php
+++ b/modules/Admin/Controllers/PodcastController.php
@@ -16,6 +16,7 @@ use App\Entities\Podcast;
 use App\Models\CategoryModel;
 use App\Models\EpisodeModel;
 use App\Models\LanguageModel;
+use App\Models\MediaModel;
 use App\Models\PodcastModel;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
@@ -192,11 +193,10 @@ class PodcastController extends BaseController
             $partnerImageUrl = null;
         }
 
-        $podcast = new Podcast([
+        $newPodcast = new Podcast([
             'title' => $this->request->getPost('title'),
             'handle' => $this->request->getPost('handle'),
             'description_markdown' => $this->request->getPost('description'),
-            'cover' => new Image($this->request->getFile('cover')),
             'language_code' => $this->request->getPost('language'),
             'category_id' => $this->request->getPost('category'),
             'parental_advisory' =>
@@ -225,17 +225,53 @@ class PodcastController extends BaseController
             'updated_by' => user_id(),
         ]);
 
+        $db = db_connect();
+        $db->transStart();
+
+        $cover = new Image([
+            'file_name' => 'cover',
+            'file_directory' => 'podcasts/' . $newPodcast->handle,
+            'sizes' => config('Images')
+                ->podcastCoverSizes,
+            'file' => $this->request->getFile('cover'),
+            'uploaded_by' => user_id(),
+            'updated_by' => user_id(),
+        ]);
+        $mediaModel = new MediaModel('image');
+        if (! ($newCoverId = $mediaModel->saveMedia($cover))) {
+            $db->transRollback();
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $mediaModel->errors());
+        }
+        $newPodcast->cover_id = $newCoverId;
+
         $bannerFile = $this->request->getFile('banner');
         if ($bannerFile !== null && $bannerFile->isValid()) {
-            $podcast->banner = new Image($bannerFile);
+            $banner = new Image([
+                'file_name' => 'banner',
+                'file_directory' => 'podcasts/' . $newPodcast->handle,
+                'sizes' => config('Images')
+                    ->podcastBannerSizes,
+                'file' => $this->request->getFile('banner'),
+                'uploaded_by' => user_id(),
+                'updated_by' => user_id(),
+            ]);
+            $mediaModel = new MediaModel('image');
+            if (! ($newBannerId = $mediaModel->saveMedia($banner))) {
+                $db->transRollback();
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $mediaModel->errors());
+            }
+
+            $newPodcast->banner_id = $newBannerId;
         }
 
         $podcastModel = new PodcastModel();
-        $db = db_connect();
-
-        $db->transStart();
-
-        if (! ($newPodcastId = $podcastModel->insert($podcast, true))) {
+        if (! ($newPodcastId = $podcastModel->insert($newPodcast, true))) {
             $db->transRollback();
             return redirect()
                 ->back()
@@ -311,7 +347,7 @@ class PodcastController extends BaseController
 
         $coverFile = $this->request->getFile('cover');
         if ($coverFile !== null && $coverFile->isValid()) {
-            $this->podcast->cover = new Image($coverFile);
+            $this->podcast->cover->setFile($coverFile);
         }
         $bannerFile = $this->request->getFile('banner');
         if ($bannerFile !== null && $bannerFile->isValid()) {
diff --git a/modules/Fediverse/Controllers/SchedulerController.php b/modules/Fediverse/Controllers/SchedulerController.php
index 9331d77b8f6e65d3243c5740684954d9f46985ba..25e840d886916c0a201955c0842d734a8f30ad71 100644
--- a/modules/Fediverse/Controllers/SchedulerController.php
+++ b/modules/Fediverse/Controllers/SchedulerController.php
@@ -36,7 +36,7 @@ class SchedulerController extends Controller
             // set activity post to delivered
             model('ActivityModel')
                 ->update($scheduledActivity->id, [
-                    'task_status' => 'delivered',
+                    'status' => 'delivered',
                 ]);
         }
     }
diff --git a/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_activities.php b/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_activities.php
index f9bec2bf5966fb582c8fae40ca36dc5029821ee5..c50c46a741b3c635e034a25ad36652361dc3c3be 100644
--- a/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_activities.php
+++ b/modules/Fediverse/Database/Migrations/2018-01-01-100000_add_activities.php
@@ -44,7 +44,7 @@ class AddActivities extends Migration
             'payload' => [
                 'type' => 'JSON',
             ],
-            'task_status' => [
+            'status' => [
                 'type' => 'ENUM',
                 'constraint' => ['queued', 'delivered'],
                 'null' => true,
diff --git a/modules/Fediverse/Entities/Activity.php b/modules/Fediverse/Entities/Activity.php
index 458f97b777edcf7f5a54bc1910f9c91b6438397b..52f23fff342786aff9266765ef1779530df4c48e 100644
--- a/modules/Fediverse/Entities/Activity.php
+++ b/modules/Fediverse/Entities/Activity.php
@@ -23,7 +23,7 @@ use RuntimeException;
  * @property Post $post
  * @property string $type
  * @property object $payload
- * @property string|null $task_status
+ * @property string|null $status
  * @property Time|null $scheduled_at
  * @property Time $created_at
  */
@@ -55,7 +55,7 @@ class Activity extends UuidEntity
         'post_id' => '?string',
         'type' => 'string',
         'payload' => 'json',
-        'task_status' => '?string',
+        'status' => '?string',
     ];
 
     public function getActor(): Actor
diff --git a/modules/Fediverse/Models/ActivityModel.php b/modules/Fediverse/Models/ActivityModel.php
index 09428848cd9a70a30ee1e910126e9b5bd3448b7e..542f81f9f721969f0829ed8045e1286565449f82 100644
--- a/modules/Fediverse/Models/ActivityModel.php
+++ b/modules/Fediverse/Models/ActivityModel.php
@@ -42,7 +42,7 @@ class ActivityModel extends BaseUuidModel
         'post_id',
         'type',
         'payload',
-        'task_status',
+        'status',
         'scheduled_at',
     ];
 
@@ -100,7 +100,7 @@ class ActivityModel extends BaseUuidModel
                 'type' => $type,
                 'payload' => $payload,
                 'scheduled_at' => $scheduledAt,
-                'task_status' => $taskStatus,
+                'status' => $taskStatus,
             ],
             true,
         );
@@ -112,7 +112,7 @@ class ActivityModel extends BaseUuidModel
     public function getScheduledActivities(): array
     {
         return $this->where('`scheduled_at` <= NOW()', null, false)
-            ->where('task_status', 'queued')
+            ->where('status', 'queued')
             ->orderBy('scheduled_at', 'ASC')
             ->findAll();
     }
diff --git a/public/.htaccess b/public/.htaccess
index a5d6c2a541286ca75c7d65fb6ac5944dfd123bd6..189ec9ae332bb964dcb56a626c3730be95561f7d 100644
--- a/public/.htaccess
+++ b/public/.htaccess
@@ -11,7 +11,7 @@ Options All -Indexes
 	Options +FollowSymlinks
 	RewriteEngine On
 
-	# If you installed CodeIgniter in a subfolder, you will need to
+	# If you installed Castopod Host in a subfolder, you will need to
 	# change the following line to match the subfolder you need.
 	# http://httpd.apache.org/docs/current/mod/mod_rewrite.html#rewritebase
 	# RewriteBase /
diff --git a/themes/cp_admin/episode/list.php b/themes/cp_admin/episode/list.php
index 8e1e96a458c466b33f1f7483e3f0017b3eb06e02..3ca83c14d4b91e692798ed147f119eeba5d3a328 100644
--- a/themes/cp_admin/episode/list.php
+++ b/themes/cp_admin/episode/list.php
@@ -29,9 +29,9 @@
                 'cell' => function ($episode, $podcast) {
                     return '<div class="flex">' .
                         '<div class="relative flex-shrink-0 mr-2">' .
-                            '<time class="absolute px-1 text-xs font-semibold text-white rounded bottom-2 right-2 bg-black/50" datetime="PT<?= $episode->audio_file_duration ?>S">' .
+                            '<time class="absolute px-1 text-xs font-semibold text-white rounded bottom-2 right-2 bg-black/50" datetime="PT<?= $episode->audio->duration ?>S">' .
                                 format_duration(
-                                    $episode->audio_file_duration,
+                                    $episode->audio->duration,
                                 ) .
                             '</time>' .
                             '<img loading="lazy" src="' . $episode->cover->thumbnail_url . '" alt="' . $episode->title . '" class="object-cover w-20 rounded-lg shadow-inner aspect-square" />' .
diff --git a/themes/cp_admin/episode/publish.php b/themes/cp_admin/episode/publish.php
index 48b2cc7d14ecc4f98d75c93d731babfe391b0ad7..f3e99324103a7815847d3418621d05819aafe655 100644
--- a/themes/cp_admin/episode/publish.php
+++ b/themes/cp_admin/episode/publish.php
@@ -54,12 +54,12 @@
     ) ?>
                 </div>
                 <div class="text-xs text-skin-muted">
-                    <time datetime="PT<?= $episode->audio_file_duration ?>S">
-                        <?= format_duration($episode->audio_file_duration) ?>
+                    <time datetime="PT<?= $episode->audio->duration ?>S">
+                        <?= format_duration($episode->audio->duration) ?>
                     </time>
                 </div>
             </a>
-            <?= audio_player($episode->audio_file_url, $episode->audio_file_mimetype, 'mt-auto') ?>
+            <?= audio_player($episode->audio->file_url, $episode->audio->file_content_type, 'mt-auto') ?>
         </div>
     </div>
     <footer class="flex justify-around px-6 py-3">
diff --git a/themes/cp_admin/episode/publish_edit.php b/themes/cp_admin/episode/publish_edit.php
index 2df15f592c42f17b54d1f6cd7052857baf28ffc2..8eb15701c172c2d74745b2ab1a19d2e8ffded5a4 100644
--- a/themes/cp_admin/episode/publish_edit.php
+++ b/themes/cp_admin/episode/publish_edit.php
@@ -58,12 +58,12 @@
                 <div class="text-xs text-skin-muted">
                     <?= relative_time($episode->published_at) ?>
                     <span class="mx-1">•</span>
-                    <time datetime="PT<?= $episode->audio_file_duration ?>S">
-                        <?= format_duration($episode->audio_file_duration) ?>
+                    <time datetime="PT<?= $episode->audio->duration ?>S">
+                        <?= format_duration($episode->audio->duration) ?>
                     </time>
                 </div>
             </a>
-            <?= audio_player($episode->audio_file_url, $episode->audio_file_mimetype, 'mt-auto') ?>
+            <?= audio_player($episode->audio->file_url, $episode->audio->file_content_type, 'mt-auto') ?>
         </div>
     </div>
     <footer class="flex justify-around px-6 py-3">
diff --git a/themes/cp_admin/episode/soundbites.php b/themes/cp_admin/episode/soundbites.php
index 1555d1474d09b6fcd93bcb0b6a600bc32ee6984f..425c989962ac9314601faeb0bb35c4d4ddbeae65 100644
--- a/themes/cp_admin/episode/soundbites.php
+++ b/themes/cp_admin/episode/soundbites.php
@@ -35,8 +35,8 @@
 
     foreach ($episode->soundbites as $soundbite) {
         $table->addRow(
-            "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio_file_duration}' name='soundbites[{$soundbite->id}][start_time]' value='{$soundbite->start_time}' data-type='soundbite-field' data-field-type='start_time' required='true' />",
-            "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio_file_duration}' name='soundbites[{$soundbite->id}][duration]' value='{$soundbite->duration}' data-type='soundbite-field' data-field-type='duration' required='true' />",
+            "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[{$soundbite->id}][start_time]' value='{$soundbite->start_time}' data-type='soundbite-field' data-field-type='start_time' required='true' />",
+            "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[{$soundbite->id}][duration]' value='{$soundbite->duration}' data-type='soundbite-field' data-field-type='duration' required='true' />",
             "<Forms.Input class='flex-1' name='soundbites[{$soundbite->id}][label]' value='{$soundbite->label}' />",
             "<IconButton variant='primary' glyph='play' data-type='play-soundbite' data-soundbite-id='{$soundbite->id}'>" . lang('Episode.soundbites_form.play') . '</IconButton>',
             '<IconButton uri=' . route_to(
@@ -49,8 +49,8 @@
     }
 
     $table->addRow(
-        "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio_file_duration}' name='soundbites[0][start_time]' data-type='soundbite-field' data-field-type='start_time' />",
-        "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio_file_duration}' name='soundbites[0][duration]' data-type='soundbite-field' data-field-type='duration' />",
+        "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[0][start_time]' data-type='soundbite-field' data-field-type='start_time' />",
+        "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[0][duration]' data-type='soundbite-field' data-field-type='duration' />",
         "<Forms.Input class='flex-1' name='soundbites[0][label]' />",
         "<IconButton variant='primary' glyph='play' data-type='play-soundbite' data-soundbite-id='0'>" . lang('Episode.soundbites_form.play') . '</IconButton>',
     );
@@ -61,7 +61,7 @@
 
     <div class="flex items-center gap-x-2">
         <audio controls preload="auto" class="flex-1 w-full">
-            <source src="<?= $episode->audio_file_url ?>" type="<?= $episode->audio_file_mimetype ?>">
+            <source src="<?= $episode->audio->file_url ?>" type="<?= $episode->audio->file_content_type ?>">
             Your browser does not support the audio tag.
         </audio>
         <IconButton glyph="timer" variant="info" data-type="get-soundbite" data-start-time-field-name="soundbites[0][start_time]" data-duration-field-name="soundbites[0][duration]" ><?= lang('Episode.soundbites_form.bookmark') ?></IconButton>
diff --git a/themes/cp_admin/episode/view.php b/themes/cp_admin/episode/view.php
index 394b4211552dd33c0b079c654be0a519457a0b88..5310bce7665f0fbad5a2ab392fa454e7cea0e1bb 100644
--- a/themes/cp_admin/episode/view.php
+++ b/themes/cp_admin/episode/view.php
@@ -28,7 +28,7 @@
 <?= $this->section('content') ?>
 
 <div class="mb-12">
-    <?= audio_player($episode->audio_file_url, $episode->audio_file_mimetype) ?>
+    <?= audio_player($episode->audio->file_url, $episode->audio->file_content_type) ?>
 </div>
 
 <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
diff --git a/themes/cp_admin/podcast/edit.php b/themes/cp_admin/podcast/edit.php
index 94befb4d0153b6a4d3e8e05379c6daf6f7a9e480..45651946c36ae68d9fa9cbc2ae20a70c9a9860ad 100644
--- a/themes/cp_admin/podcast/edit.php
+++ b/themes/cp_admin/podcast/edit.php
@@ -22,7 +22,7 @@
 <?= csrf_field() ?>
 
 <div class="sticky z-40 flex flex-col w-full max-w-xs overflow-hidden shadow-sm bg-elevated border-3 border-subtle top-24 rounded-xl">
-    <?php if ($podcast->banner_path !== null): ?>
+    <?php if ($podcast->banner_id !== null): ?>
         <a href="<?= route_to('podcast-banner-delete', $podcast->id) ?>" class="absolute p-1 text-red-700 bg-red-100 border-2 rounded-full hover:text-red-900 border-contrast focus:ring-accent top-2 right-2" title="<?= lang('Podcast.form.banner_delete') ?>" data-tooltip="bottom"><?= icon('delete-bin') ?></a>
     <?php endif; ?>
     <img src="<?= $podcast->banner->small_url ?>" alt="" class="object-cover w-full aspect-[3/1] bg-header" />
diff --git a/themes/cp_app/embed.php b/themes/cp_app/embed.php
index 698a63cc33d276796bfa4717dc54d57b42b4b9a4..7b49fae61c1f15973018f49cbf77eb0ab49ae694 100644
--- a/themes/cp_app/embed.php
+++ b/themes/cp_app/embed.php
@@ -41,12 +41,12 @@
                 style="--vm-player-box-shadow:0; --vm-player-theme: hsl(var(--color-accent-base)); --vm-control-focus-color: hsl(var(--color-accent-contrast)); --vm-control-spacing: 4px; --vm-menu-item-focus-bg: hsl(var(--color-background-highlight)); --vm-control-icon-size: 24px; <?= str_ends_with($theme, 'transparent') ? '--vm-controls-bg: transparent;' : '' ?>"
             >
             <vm-audio preload="none">
-                <?php $source = logged_in() ? $episode->audio_file_url : $episode->audio_file_analytics_url .
+                <?php $source = logged_in() ? $episode->audio->file_url : $episode->audio_file_analytics_url .
                     (isset($_SERVER['HTTP_REFERER'])
                         ? '?_from=' .
                             parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST)
                         : '') ?>
-                <source src="<?= $source ?>" type="<?= $episode->audio_file_mimetype ?>" />
+                <source src="<?= $source ?>" type="<?= $episode->audio->file_content_type ?>" />
             </vm-audio>
             <vm-ui>
                 <vm-icon-library name="castopod-icons"></vm-icon-library>
diff --git a/themes/cp_app/episode/_layout.php b/themes/cp_app/episode/_layout.php
index 0276b40ef2ee98e250b5635bee2eb1f3581a9344..80cb7e1350f6dd83f22a93b8bc81d322a95f3b18 100644
--- a/themes/cp_app/episode/_layout.php
+++ b/themes/cp_app/episode/_layout.php
@@ -115,14 +115,14 @@
                 title="<?= $episode->title ?>"
                 podcast="<?= $episode->podcast->title ?>"
                 src="<?= $episode->audio_file_web_url ?>"
-                mediaType="<?= $episode->audio_file_mimetype ?>"
+                mediaType="<?= $episode->audio->file_content_type ?>"
                 playLabel="<?= lang('Common.play_episode_button.play') ?>"
                 playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button>
             <div class="text-xs">
                 <?= relative_time($episode->published_at) ?>
                 <span class="mx-1">•</span>
-                <time datetime="PT<?= $episode->audio_file_duration ?>S">
-                    <?= format_duration_symbol($episode->audio_file_duration) ?>
+                <time datetime="PT<?= $episode->audio->duration ?>S">
+                    <?= format_duration_symbol($episode->audio->duration) ?>
                 </time>
             </div>
         </div>
diff --git a/themes/cp_app/episode/_partials/card.php b/themes/cp_app/episode/_partials/card.php
index df1e327c6dc49cf5d6f7320d1b3086baeaa3e070..1105015ddde91511dcfee6662bc88aa609c8c72c 100644
--- a/themes/cp_app/episode/_partials/card.php
+++ b/themes/cp_app/episode/_partials/card.php
@@ -1,7 +1,7 @@
 <article class="flex w-full p-4 shadow bg-elevated rounded-conditional-2xl gap-x-2">
     <div class="relative">
-        <time class="absolute px-1 text-xs font-semibold text-white rounded bottom-2 right-2 bg-black/75" datetime="PT<?= $episode->audio_file_duration ?>S">
-            <?= format_duration($episode->audio_file_duration) ?>
+        <time class="absolute px-1 text-xs font-semibold text-white rounded bottom-2 right-2 bg-black/75" datetime="PT<?= $episode->audio->duration ?>S">
+            <?= format_duration($episode->audio->duration) ?>
         </time>
         <img loading="lazy" src="<?= $episode->cover
                 ->thumbnail_url ?>" alt="<?= $episode->title ?>" class="object-cover w-20 rounded-lg shadow-inner aspect-square" />
@@ -20,7 +20,7 @@
             title="<?= $episode->title ?>"
             podcast="<?= $episode->podcast->title ?>"
             src="<?= $episode->audio_file_web_url ?>"
-            mediaType="<?= $episode->audio_file_mimetype ?>"
+            mediaType="<?= $episode->audio->file_content_type ?>"
             playLabel="<?= lang('Common.play_episode_button.play') ?>"
             playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button>
     </div>
diff --git a/themes/cp_app/episode/_partials/preview_card.php b/themes/cp_app/episode/_partials/preview_card.php
index a3248251779e802c83ca1349b692716be23d2f6e..d9b97d8043723f0faab6135a72be63b5a93575ee 100644
--- a/themes/cp_app/episode/_partials/preview_card.php
+++ b/themes/cp_app/episode/_partials/preview_card.php
@@ -1,7 +1,7 @@
 <div class="flex items-center border-y border-subtle">
     <div class="relative">
-        <time class="absolute px-1 text-sm font-semibold text-white rounded bg-black/75 bottom-2 right-2" datetime="PT<?= $episode->audio_file_duration ?>S">
-                    <?= format_duration($episode->audio_file_duration) ?>
+        <time class="absolute px-1 text-sm font-semibold text-white rounded bg-black/75 bottom-2 right-2" datetime="PT<?= $episode->audio->duration ?>S">
+                    <?= format_duration($episode->audio->duration) ?>
         </time>
         <img
         src="<?= $episode->cover->thumbnail_url ?>"
@@ -21,7 +21,7 @@
         title="<?= $episode->title ?>"
         podcast="<?= $episode->podcast->title ?>"
         src="<?= $episode->audio_file_web_url ?>"
-        mediaType="<?= $episode->audio_file_mimetype ?>"
+        mediaType="<?= $episode->audio->file_content_type ?>"
         playLabel="<?= lang('Common.play_episode_button.play') ?>"
         playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button>
 </div>
\ No newline at end of file