diff --git a/app/Database/Migrations/2020-05-29-120000_add_media.php b/app/Database/Migrations/2020-05-29-120000_add_media.php
index 711ba069f855c7bc66ecf5fc5c3871396fc213d7..6f80aad11463276eb67441428f6e4909e58e367e 100644
--- a/app/Database/Migrations/2020-05-29-120000_add_media.php
+++ b/app/Database/Migrations/2020-05-29-120000_add_media.php
@@ -31,7 +31,7 @@ class AddMedia extends Migration
                 'unsigned' => true,
                 'comment' => 'File size in bytes',
             ],
-            'file_content_type' => [
+            'file_mimetype' => [
                 'type' => 'VARCHAR',
                 'constraint' => 45,
             ],
@@ -42,6 +42,7 @@ class AddMedia extends Migration
             'type' => [
                 'type' => 'ENUM',
                 'constraint' => ['image', 'audio', 'video', 'transcript', 'chapters', 'document'],
+                'default' => 'document',
             ],
             'description' => [
                 'type' => 'TEXT',
@@ -71,6 +72,7 @@ class AddMedia extends Migration
         ]);
 
         $this->forge->addKey('id', true);
+        $this->forge->addUniqueKey('file_path');
         $this->forge->addForeignKey('uploaded_by', 'users', 'id');
         $this->forge->addForeignKey('updated_by', 'users', 'id');
         $this->forge->createTable('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 ff2f913c470720ca8b526edef09e677a6e19edc4..436c741b5fd872bf4f7791aa487f0b7df5140380 100644
--- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
+++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
@@ -198,7 +198,7 @@ class AddPodcasts extends Migration
         $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('banner_id', 'media', 'id', '', 'SET NULL');
         $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 04656978eacce8a57e1bbbf4cbf456b0bb5d7405..9e0efaa25078539b49e870f16f09f500500b41dc 100644
--- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php
+++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
@@ -157,9 +157,9 @@ class AddEpisodes extends Migration
         $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('cover_id', 'media', 'id', '', 'SET NULL');
+        $this->forge->addForeignKey('transcript_id', 'media', 'id', '', 'SET NULL');
+        $this->forge->addForeignKey('chapters_id', 'media', 'id', '', 'SET NULL');
         $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 66b53ba1ad088a375d2de4eee336c959267d86e3..f7515ed49974dbafc8f6922c3499e67af0627301 100644
--- a/app/Database/Migrations/2020-12-25-120000_add_persons.php
+++ b/app/Database/Migrations/2020-12-25-120000_add_persons.php
@@ -64,7 +64,7 @@ class AddPersons extends Migration
         ]);
 
         $this->forge->addKey('id', true);
-        $this->forge->addForeignKey('avatar_id', 'media', 'id');
+        $this->forge->addForeignKey('avatar_id', 'media', 'id', '', 'SET NULL');
         $this->forge->addForeignKey('created_by', 'users', 'id');
         $this->forge->addForeignKey('updated_by', 'users', 'id');
         $this->forge->createTable('persons');
diff --git a/app/Entities/BaseEntity.php b/app/Entities/BaseEntity.php
new file mode 100644
index 0000000000000000000000000000000000000000..b9f888a463101765cfdf0f9087576852abf81f0a
--- /dev/null
+++ b/app/Entities/BaseEntity.php
@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Entities;
+
+use CodeIgniter\Entity\Entity;
+
+class BaseEntity extends Entity
+{
+}
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index b5ca18463e8972f73cfe051d4549b5257fc27758..dbdb47ccde4b6115d0e80380d7ed1c26b4b34fa8 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -10,6 +10,10 @@ declare(strict_types=1);
 
 namespace App\Entities;
 
+use App\Entities\Media\Audio;
+use App\Entities\Media\Chapters;
+use App\Entities\Media\Image;
+use App\Entities\Media\Transcript;
 use App\Libraries\SimpleRSSElement;
 use App\Models\ClipsModel;
 use App\Models\EpisodeCommentModel;
@@ -19,6 +23,7 @@ use App\Models\PodcastModel;
 use App\Models\PostModel;
 use CodeIgniter\Entity\Entity;
 use CodeIgniter\Files\File;
+use CodeIgniter\HTTP\Files\UploadedFile;
 use CodeIgniter\I18n\Time;
 use League\CommonMark\CommonMarkConverter;
 use RuntimeException;
@@ -42,10 +47,10 @@ use RuntimeException;
  * @property int $cover_id
  * @property Image $cover
  * @property int|null $transcript_id
- * @property Media|null $transcript
+ * @property Transcript|null $transcript
  * @property string|null $transcript_remote_url
  * @property int|null $chapters_id
- * @property Media|null $chapters
+ * @property Chapters|null $chapters
  * @property string|null $chapters_remote_url
  * @property string|null $parental_advisory
  * @property int $number
@@ -69,7 +74,7 @@ use RuntimeException;
  * @property Time|null $deleted_at;
  *
  * @property Person[] $persons;
- * @property Soundbite[] $soundbites;
+ * @property Clip[] $clips;
  * @property string $embed_url;
  */
 class Episode extends Entity
@@ -78,7 +83,7 @@ class Episode extends Entity
 
     protected string $link;
 
-    protected Audio $audio;
+    protected ?Audio $audio = null;
 
     protected string $audio_url;
 
@@ -90,13 +95,13 @@ class Episode extends Entity
 
     protected string $embed_url;
 
-    protected Image $cover;
+    protected ?Image $cover = null;
 
     protected ?string $description = null;
 
-    protected ?Media $transcript;
+    protected ?Transcript $transcript = null;
 
-    protected ?Media $chapters;
+    protected ?Chapters $chapters = null;
 
     /**
      * @var Person[]|null
@@ -161,25 +166,31 @@ class Episode extends Entity
         'updated_by' => 'integer',
     ];
 
-    /**
-     * Saves an episode cover
-     */
-    public function setCover(?Image $cover = null): static
+    public function setCover(?UploadedFile $file): self
     {
-        if ($cover === null) {
+        if ($file === null || ! $file->isValid()) {
             return $this;
         }
 
-        // Save image
-        $cover->saveImage(
-            config('Images')
-                ->podcastCoverSizes,
-            'podcasts/' . $this->getPodcast()->handle,
-            $this->attributes['slug']
-        );
-
-        $this->attributes['cover_mimetype'] = $cover->mimetype;
-        $this->attributes['cover_path'] = $cover->path;
+        if (array_key_exists('cover_id', $this->attributes) && $this->attributes['cover_id'] !== null) {
+            $this->getCover()
+                ->setFile($file);
+            $this->getCover()
+                ->updated_by = (int) user_id();
+            (new MediaModel('image'))->updateMedia($this->getCover());
+        } else {
+            $cover = new Image([
+                'file_name' => $this->attributes['slug'],
+                'file_directory' => 'podcasts/' . $this->attributes['handle'],
+                'sizes' => config('Images')
+                    ->podcastCoverSizes,
+                'uploaded_by' => user_id(),
+                'updated_by' => user_id(),
+            ]);
+            $cover->setFile($file);
+
+            $this->attributes['cover_id'] = (new MediaModel('image'))->saveMedia($cover);
+        }
 
         return $this;
     }
@@ -193,25 +204,106 @@ class Episode extends Entity
         return $this->cover;
     }
 
+    public function setAudio(?UploadedFile $file): self
+    {
+        if ($file === null || ! $file->isValid()) {
+            return $this;
+        }
+
+        if ($this->audio_id !== null) {
+            $this->getAudio()
+                ->setFile($file);
+            $this->getAudio()
+                ->updated_by = (int) user_id();
+            (new MediaModel('audio'))->updateMedia($this->getAudio());
+        } else {
+            $transcript = new Audio([
+                'file_name' => $this->attributes['slug'],
+                'file_directory' => 'podcasts/' . $this->attributes['handle'],
+                'uploaded_by' => user_id(),
+                'updated_by' => user_id(),
+            ]);
+            $transcript->setFile($file);
+
+            $this->attributes['transcript_id'] = (new MediaModel())->saveMedia($transcript);
+        }
+
+        return $this;
+    }
+
     public function getAudio(): Audio
     {
-        if (! $this->audio) {
+        if (! $this->audio instanceof Audio) {
             $this->audio = (new MediaModel('audio'))->getMediaById($this->audio_id);
         }
 
         return $this->audio;
     }
 
-    public function getTranscript(): ?Media
+    public function setTranscript(?UploadedFile $file): self
+    {
+        if ($file === null || ! $file->isValid()) {
+            return $this;
+        }
+
+        if ($this->getTranscript() !== null) {
+            $this->getTranscript()
+                ->setFile($file);
+            $this->getTranscript()
+                ->updated_by = (int) user_id();
+            (new MediaModel('transcript'))->updateMedia($this->getTranscript());
+        } else {
+            $transcript = new Transcript([
+                'file_name' => $this->attributes['slug'] . '-transcript',
+                'file_directory' => 'podcasts/' . $this->attributes['handle'],
+                'uploaded_by' => user_id(),
+                'updated_by' => user_id(),
+            ]);
+            $transcript->setFile($file);
+
+            $this->attributes['transcript_id'] = (new MediaModel())->saveMedia($transcript);
+        }
+
+        return $this;
+    }
+
+    public function getTranscript(): ?Transcript
     {
         if ($this->transcript_id !== null && $this->transcript === null) {
-            $this->transcript = (new MediaModel('document'))->getMediaById($this->transcript_id);
+            $this->transcript = (new MediaModel('transcript'))->getMediaById($this->transcript_id);
         }
 
         return $this->transcript;
     }
 
-    public function getChaptersFile(): ?Media
+    public function setChapters(?UploadedFile $file): self
+    {
+        if ($file === null || ! $file->isValid()) {
+            return $this;
+        }
+
+        if ($this->getChapters() !== null) {
+            $this->getChapters()
+                ->setFile($file);
+            $this->getChapters()
+                ->updated_by = (int) user_id();
+            (new MediaModel('chapters'))->updateMedia($this->getChapters());
+        } else {
+            $chapters = new Chapters([
+                'file_name' => $this->attributes['slug'] . '-chapters',
+                'file_directory' => 'podcasts/' . $this->attributes['handle'],
+                'uploaded_by' => user_id(),
+                'updated_by' => user_id(),
+            ]);
+            $chapters->setFile($file);
+
+            $this->attributes['chapters_id'] = (new MediaModel())->saveMedia($chapters);
+        }
+
+        return $this;
+    }
+
+    public function getChapters(): ?Chapters
     {
         if ($this->chapters_id !== null && $this->chapters === null) {
             $this->chapters = (new MediaModel('document'))->getMediaById($this->chapters_id);
@@ -261,7 +353,7 @@ class Episode extends Entity
     public function getTranscriptUrl(): ?string
     {
         if ($this->transcript !== null) {
-            return $this->transcript->url;
+            return $this->transcript->file_url;
         }
         return $this->transcript_remote_url;
     }
@@ -271,8 +363,8 @@ class Episode extends Entity
      */
     public function getChaptersFileUrl(): ?string
     {
-        if ($this->chapters) {
-            return $this->chapters->url;
+        if ($this->chapters !== null) {
+            return $this->chapters->file_url;
         }
 
         return $this->chapters_remote_url;
diff --git a/app/Entities/ImageOLD.php b/app/Entities/ImageOLD.php
deleted file mode 100644
index d46b29e141ff8a08ff565b34be02df11b12eb326..0000000000000000000000000000000000000000
--- a/app/Entities/ImageOLD.php
+++ /dev/null
@@ -1,123 +0,0 @@
-<?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/Audio.php b/app/Entities/Media/Audio.php
similarity index 89%
rename from app/Entities/Audio.php
rename to app/Entities/Media/Audio.php
index 4a342d41db30a5bb9eeacc3483b6ff442ecd0b62..288f95506ee66df297bbd333cab5c6b6ed645920 100644
--- a/app/Entities/Audio.php
+++ b/app/Entities/Media/Audio.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Entities;
+namespace App\Entities\Media;
 
 use CodeIgniter\Files\File;
 use JamesHeinrich\GetID3\GetID3;
@@ -17,7 +17,7 @@ use JamesHeinrich\GetID3\GetID3;
  * @property float $duration
  * @property int $header_size
  */
-class Audio extends Media
+class Audio extends BaseMedia
 {
     protected string $type = 'audio';
 
@@ -41,7 +41,7 @@ class Audio extends Media
         $getID3 = new GetID3();
         $audioMetadata = $getID3->analyze((string) $file);
 
-        $this->attributes['file_content_type'] = $audioMetadata['mimetype'];
+        $this->attributes['file_mimetype'] = $audioMetadata['mimetype'];
         $this->attributes['file_size'] = $audioMetadata['filesize'];
         $this->attributes['description'] = $audioMetadata['comments']['comment'];
         $this->attributes['file_metadata'] = $audioMetadata;
diff --git a/app/Entities/Media.php b/app/Entities/Media/BaseMedia.php
similarity index 74%
rename from app/Entities/Media.php
rename to app/Entities/Media/BaseMedia.php
index b979edbc9c458d12ee167420698197a198a1b205..36b672b99788b5e402b406507b8ce99ff4f7c913 100644
--- a/app/Entities/Media.php
+++ b/app/Entities/Media/BaseMedia.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Entities;
+namespace App\Entities\Media;
 
 use CodeIgniter\Entity\Entity;
 use CodeIgniter\Files\File;
@@ -16,11 +16,12 @@ use CodeIgniter\Files\File;
 /**
  * @property int $id
  * @property string $file_path
+ * @property string $file_url
  * @property string $file_directory
  * @property string $file_extension
  * @property string $file_name
  * @property int $file_size
- * @property string $file_content_type
+ * @property string $file_mimetype
  * @property array $file_metadata
  * @property 'image'|'audio'|'video'|'document' $type
  * @property string $description
@@ -28,7 +29,7 @@ use CodeIgniter\Files\File;
  * @property int $uploaded_by
  * @property int $updated_by
  */
-class Media extends Entity
+class BaseMedia extends Entity
 {
     protected File $file;
 
@@ -44,9 +45,10 @@ class Media extends Entity
      */
     protected $casts = [
         'id' => 'integer',
+        'file_extension' => 'string',
         'file_path' => 'string',
         'file_size' => 'int',
-        'file_content_type' => 'string',
+        'file_mimetype' => 'string',
         'file_metadata' => 'json-array',
         'type' => 'string',
         'description' => 'string',
@@ -62,16 +64,23 @@ class Media extends Entity
     {
         parent::__construct($data);
 
-        if ($this->file_path) {
+        $this->initFileProperties();
+    }
+
+    public function initFileProperties(): void
+    {
+        if ($this->file_path !== '') {
+            helper('media');
             [
                 'filename' => $filename,
                 'dirname' => $dirname,
                 'extension' => $extension,
             ] = pathinfo($this->file_path);
 
-            $this->file_name = $filename;
-            $this->file_directory = $dirname;
-            $this->file_extension = $extension;
+            $this->attributes['file_url'] = media_base_url($this->file_path);
+            $this->attributes['file_name'] = $filename;
+            $this->attributes['file_directory'] = $dirname;
+            $this->attributes['file_extension'] = $extension;
         }
     }
 
@@ -79,7 +88,7 @@ class Media extends Entity
     {
         helper('media');
 
-        $this->attributes['file_content_type'] = $file->getMimeType();
+        $this->attributes['file_mimetype'] = $file->getMimeType();
         $this->attributes['file_metadata'] = json_encode(lstat((string) $file));
         $this->attributes['file_path'] = save_media(
             $file,
diff --git a/app/Entities/Media/Chapters.php b/app/Entities/Media/Chapters.php
new file mode 100644
index 0000000000000000000000000000000000000000..48e7fc744d789b4bb5b6717dc6544446d173dc66
--- /dev/null
+++ b/app/Entities/Media/Chapters.php
@@ -0,0 +1,16 @@
+<?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\Media;
+
+class Chapters extends BaseMedia
+{
+    protected string $type = 'chapters';
+}
diff --git a/app/Entities/Media/Document.php b/app/Entities/Media/Document.php
new file mode 100644
index 0000000000000000000000000000000000000000..dc7c3e903e1ac167637e0dd548ae8f27e2c3ccc5
--- /dev/null
+++ b/app/Entities/Media/Document.php
@@ -0,0 +1,16 @@
+<?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\Media;
+
+class Document extends BaseMedia
+{
+    protected string $type = 'document';
+}
diff --git a/app/Entities/Image.php b/app/Entities/Media/Image.php
similarity index 72%
rename from app/Entities/Image.php
rename to app/Entities/Media/Image.php
index 758e2fa4c55b37ef7ed354fbe140fad3ebd7a839..fff51e00a6f730da9b5a3d5f3729084187c5c91f 100644
--- a/app/Entities/Image.php
+++ b/app/Entities/Media/Image.php
@@ -8,20 +8,20 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace App\Entities;
+namespace App\Entities\Media;
 
 use CodeIgniter\Files\File;
 
-class Image extends Media
+/**
+ * @property array $sizes
+ */
+class Image extends BaseMedia
 {
     protected string $type = 'image';
 
-    /**
-     * @param array<string, mixed>|null $data
-     */
-    public function __construct(array $data = null)
+    public function initFileProperties(): void
     {
-        parent::__construct($data);
+        parent::initFileProperties();
 
         if ($this->file_path && $this->file_metadata) {
             $this->sizes = $this->file_metadata['sizes'];
@@ -34,7 +34,7 @@ class Image extends Media
         helper('media');
 
         $extension = $this->file_extension;
-        $mimetype = $this->mimetype;
+        $mimetype = $this->file_mimetype;
         foreach ($this->sizes as $name => $size) {
             if (array_key_exists('extension', $size)) {
                 $extension = $size['extension'];
@@ -62,6 +62,23 @@ class Image extends Media
             $this->attributes['file_metadata'] = json_encode($metadata);
         }
 
+        $this->initFileProperties();
+        $this->saveSizes();
+
+        return $this;
+    }
+
+    public function deleteFile(): void
+    {
+        helper('media');
+
+        unlink(media_path($this->file_path));
+
+        $this->deleteSizes();
+    }
+
+    private function saveSizes(): void
+    {
         // save derived sizes
         $imageService = service('image');
         foreach ($this->sizes as $name => $size) {
@@ -71,7 +88,14 @@ class Image extends Media
                 ->resize($size['width'], $size['height']);
             $imageService->save(media_path($this->{$pathProperty}));
         }
+    }
 
-        return $this;
+    private function deleteSizes(): void
+    {
+        // delete all derived sizes
+        foreach (array_keys($this->sizes) as $name) {
+            $pathProperty = $name . '_path';
+            unlink(media_path($this->{$pathProperty}));
+        }
     }
 }
diff --git a/app/Entities/Media/Transcript.php b/app/Entities/Media/Transcript.php
new file mode 100644
index 0000000000000000000000000000000000000000..2a06ef1d4c94bf2c40c78cfaa461b16f6a2ea63b
--- /dev/null
+++ b/app/Entities/Media/Transcript.php
@@ -0,0 +1,16 @@
+<?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\Media;
+
+class Transcript extends BaseMedia
+{
+    protected string $type = 'transcript';
+}
diff --git a/app/Entities/Media/Video.php b/app/Entities/Media/Video.php
new file mode 100644
index 0000000000000000000000000000000000000000..f003283bd711da95b393022c000dab3451304f48
--- /dev/null
+++ b/app/Entities/Media/Video.php
@@ -0,0 +1,16 @@
+<?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\Media;
+
+class Video extends BaseMedia
+{
+    protected string $type = 'video';
+}
diff --git a/app/Entities/MediaOLD.php b/app/Entities/MediaOLD.php
deleted file mode 100644
index d585ad853a16c6475cf265ee55f26bf6e8aa7edb..0000000000000000000000000000000000000000
--- a/app/Entities/MediaOLD.php
+++ /dev/null
@@ -1,93 +0,0 @@
-<?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 10e9fd2273ce56dba44dc993db5fae53f695817c..c8b8478913f6037ae81e35f7ba5e9e22f123e3c3 100644
--- a/app/Entities/Person.php
+++ b/app/Entities/Person.php
@@ -10,8 +10,11 @@ declare(strict_types=1);
 
 namespace App\Entities;
 
+use App\Entities\Media\Image;
+use App\Models\MediaModel;
 use App\Models\PersonModel;
 use CodeIgniter\Entity\Entity;
+use CodeIgniter\HTTP\Files\UploadedFile;
 use RuntimeException;
 
 /**
@@ -52,31 +55,52 @@ class Person extends Entity
     /**
      * Saves the person avatar in `public/media/persons/`
      */
-    public function setAvatar(?Image $avatar = null): static
+    public function setAvatar(?UploadedFile $file = null): static
     {
-        if ($avatar === null) {
+        if ($file === null || ! $file->isValid()) {
             return $this;
         }
 
-        helper('media');
-
-        $avatar->saveImage(config('Images')->personAvatarSizes, 'persons', $this->attributes['unique_name']);
-
-        $this->attributes['avatar_mimetype'] = $avatar->mimetype;
-        $this->attributes['avatar_path'] = $avatar->path;
+        if (array_key_exists('cover_id', $this->attributes) && $this->attributes['cover_id'] !== null) {
+            $this->getAvatar()
+                ->setFile($file);
+            $this->getAvatar()
+                ->updated_by = (int) user_id();
+            (new MediaModel('image'))->updateMedia($this->getAvatar());
+        } else {
+            $cover = new Image([
+                'file_name' => $this->attributes['unique_name'],
+                'file_directory' => 'persons',
+                'sizes' => config('Images')
+                    ->personAvatarSizes,
+                'uploaded_by' => user_id(),
+                'updated_by' => user_id(),
+            ]);
+            $cover->setFile($file);
+
+            $this->attributes['cover_id'] = (new MediaModel('image'))->saveMedia($cover);
+        }
 
         return $this;
     }
 
     public function getAvatar(): Image
     {
-        if ($this->attributes['avatar_path'] === null) {
-            return new Image(null, '/castopod-avatar-default.jpg', 'image/jpeg', config('Images')->personAvatarSizes);
+        if ($this->attributes['avatar_id'] === null) {
+            helper('media');
+            return new Image([
+                'file_path' => media_path('castopod-avatar-default.jpg'),
+                'file_mimetype' => 'image/jpeg',
+                'sizes' => config('Images')
+                    ->personAvatarSizes,
+            ]);
+        }
+
+        if ($this->avatar === null) {
+            $this->avatar = (new MediaModel('image'))->getMediaById($this->avatar_id);
         }
 
-        return new Image(null, $this->attributes['avatar_path'], $this->attributes['avatar_mimetype'], config(
-            'Images'
-        )->personAvatarSizes);
+        return $this->avatar;
     }
 
     /**
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 1663a6b32fcee5b3ecae96671a7ed21c0d771ed8..d2e5a04bb7996848be9f97d7e1236ce7f38735c2 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -10,6 +10,7 @@ declare(strict_types=1);
 
 namespace App\Entities;
 
+use App\Entities\Media\Image;
 use App\Libraries\SimpleRSSElement;
 use App\Models\CategoryModel;
 use App\Models\EpisodeModel;
@@ -18,6 +19,7 @@ use App\Models\PersonModel;
 use App\Models\PlatformModel;
 use App\Models\UserModel;
 use CodeIgniter\Entity\Entity;
+use CodeIgniter\HTTP\Files\UploadedFile;
 use CodeIgniter\I18n\Time;
 use League\CommonMark\CommonMarkConverter;
 use Modules\Auth\Entities\User;
@@ -192,6 +194,35 @@ class Podcast extends Entity
         return $this->actor;
     }
 
+    public function setCover(?UploadedFile $file = null): self
+    {
+        if ($file === null || ! $file->isValid()) {
+            return $this;
+        }
+
+        if (array_key_exists('cover_id', $this->attributes) && $this->attributes['cover_id'] !== null) {
+            $this->getCover()
+                ->setFile($file);
+            $this->getCover()
+                ->updated_by = (int) user_id();
+            (new MediaModel('image'))->updateMedia($this->getCover());
+        } else {
+            $cover = new Image([
+                'file_name' => 'cover',
+                'file_directory' => 'podcasts/' . $this->attributes['handle'],
+                'sizes' => config('Images')
+                    ->podcastCoverSizes,
+                'uploaded_by' => user_id(),
+                'updated_by' => user_id(),
+            ]);
+            $cover->setFile($file);
+
+            $this->attributes['cover_id'] = (new MediaModel('image'))->saveMedia($cover);
+        }
+
+        return $this;
+    }
+
     public function getCover(): Image
     {
         if (! $this->cover instanceof Image) {
@@ -201,6 +232,35 @@ class Podcast extends Entity
         return $this->cover;
     }
 
+    public function setBanner(?UploadedFile $file): self
+    {
+        if ($file === null || ! $file->isValid()) {
+            return $this;
+        }
+
+        if (array_key_exists('banner_id', $this->attributes) && $this->attributes['banner_id'] !== null) {
+            $this->getBanner()
+                ->setFile($file);
+            $this->getBanner()
+                ->updated_by = (int) user_id();
+            (new MediaModel('image'))->updateMedia($this->getBanner());
+        } else {
+            $banner = new Image([
+                'file_name' => 'banner',
+                'file_directory' => 'podcasts/' . $this->attributes['handle'],
+                'sizes' => config('Images')
+                    ->podcastBannerSizes,
+                'uploaded_by' => user_id(),
+                'updated_by' => user_id(),
+            ]);
+            $banner->setFile($file);
+
+            $this->attributes['banner_id'] = (new MediaModel('image'))->saveMedia($banner);
+        }
+
+        return $this;
+    }
+
     public function getBanner(): Image
     {
         if ($this->banner_id === null) {
diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php
index fbb845d8f82f47c6fd88ae46f503e92116a30d15..93e40cf2dc41f61bf769746bc6efbbd9672892a6 100644
--- a/app/Helpers/rss_helper.php
+++ b/app/Helpers/rss_helper.php
@@ -212,7 +212,7 @@ if (! function_exists('get_rss_feed')) {
                         : '?_from=' . urlencode($serviceSlug)),
             );
             $enclosure->addAttribute('length', (string) $episode->audio->file_size);
-            $enclosure->addAttribute('type', $episode->audio->file_content_type);
+            $enclosure->addAttribute('type', $episode->audio->file_mimetype);
 
             $item->addChild('guid', $episode->guid);
             $item->addChild('pubDate', $episode->published_at->format(DATE_RFC1123));
@@ -255,25 +255,25 @@ 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('url', $episode->transcript->file_url);
                 $transcriptElement->addAttribute(
                     'type',
                     Mimes::guessTypeFromExtension(
-                        pathinfo($episode->transcript_file_url, PATHINFO_EXTENSION)
+                        pathinfo($episode->transcript->file_url, PATHINFO_EXTENSION)
                     ) ?? 'text/html',
                 );
                 $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('url', $episode->chapters->file_url);
                 $chaptersElement->addAttribute('type', 'application/json+chapters');
             }
 
-            foreach ($episode->clip as $clip) {
+            foreach ($episode->clips as $clip) {
                 // TODO: differentiate video from soundbites?
                 $soundbiteElement = $item->addChild('soundbite', $clip->label, $podcastNamespace);
                 $soundbiteElement->addAttribute('start_time', (string) $clip->start_time);
diff --git a/app/Helpers/seo_helper.php b/app/Helpers/seo_helper.php
index 72b0c790117f1382e5f73dfcc951ceedb80a500c..6c4d9e8325e81b5e29df77800dbc05716e98cf3a 100644
--- a/app/Helpers/seo_helper.php
+++ b/app/Helpers/seo_helper.php
@@ -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_content_type)
+            ->og('audio:type', $episode->audio->file_mimetype)
             ->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 647e8545c4c0b0953e4432185ad5633f0c9e122f..ad309feffda5472bc526aeed074b614ad9976b54 100644
--- a/app/Libraries/MediaClipper/VideoClip.php
+++ b/app/Libraries/MediaClipper/VideoClip.php
@@ -80,7 +80,7 @@ class VideoClip
         helper(['media']);
 
         $this->audioInput = media_path($this->episode->audio->file_path);
-        $this->episodeCoverPath = media_path($this->episode->cover->path);
+        $this->episodeCoverPath = media_path($this->episode->cover->file_path);
         if ($this->episode->transcript !== null) {
             $this->subtitlesInput = media_path($this->episode->transcript->file_path);
         }
diff --git a/app/Libraries/PodcastEpisode.php b/app/Libraries/PodcastEpisode.php
index 5d7aece560d4676b131be8b4ac9a2d4f82382ad6..a118315d0ad928be80f779de34116142a3c8a9bd 100644
--- a/app/Libraries/PodcastEpisode.php
+++ b/app/Libraries/PodcastEpisode.php
@@ -52,7 +52,7 @@ class PodcastEpisode extends ObjectType
 
         $this->image = [
             'type' => 'Image',
-            'mediaType' => $episode->cover->file_content_type,
+            'mediaType' => $episode->cover->file_mimetype,
             'url' => $episode->cover->feed_url,
         ];
 
@@ -66,7 +66,7 @@ class PodcastEpisode extends ObjectType
             'url' => [
                 'href' => $episode->audio->file_url,
                 'type' => 'Link',
-                'mediaType' => $episode->audio->file_content_type,
+                'mediaType' => $episode->audio->file_mimetype,
             ],
             'transcript' => $episode->transcript->file_url,
             'chapters' => $episode->chapters->file_url,
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index 043e4fb697238f11300723bca3160727d587691f..1152adeef922216730d427238fbe71ee602ecfcc 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -68,14 +68,14 @@ class EpisodeModel extends Model
         'guid',
         'title',
         'slug',
-        'audio_file_id',
+        'audio_id',
         'description_markdown',
         'description_html',
         'cover_id',
-        'transcript_file_id',
-        'transcript_file_remote_url',
-        'chapters_file_id',
-        'chapters_file_remote_url',
+        'transcript_id',
+        'transcript_remote_url',
+        'chapters_id',
+        'chapters_remote_url',
         'parental_advisory',
         'number',
         'season_number',
@@ -114,13 +114,13 @@ class EpisodeModel extends Model
         'podcast_id' => 'required',
         'title' => 'required',
         'slug' => 'required|regex_match[/^[a-zA-Z0-9\-]{1,191}$/]',
-        'audio_file_id' => 'required',
+        'audio_id' => 'required',
         'description_markdown' => 'required',
         'number' => 'is_natural_no_zero|permit_empty',
         'season_number' => 'is_natural_no_zero|permit_empty',
         'type' => 'required',
-        'transcript_file_remote_url' => 'valid_url|permit_empty',
-        'chapters_file_remote_url' => 'valid_url|permit_empty',
+        'transcript_remote_url' => 'valid_url|permit_empty',
+        'chapters_remote_url' => 'valid_url|permit_empty',
         'published_at' => 'valid_date|permit_empty',
         'created_by' => 'required',
         'updated_by' => 'required',
diff --git a/app/Models/MediaModel.php b/app/Models/MediaModel.php
index 6d760524daadf65c7593073117504d7656291412..ceea6a957fee43bc3e8a3905d712f6727138dbdf 100644
--- a/app/Models/MediaModel.php
+++ b/app/Models/MediaModel.php
@@ -10,9 +10,12 @@ declare(strict_types=1);
 
 namespace App\Models;
 
-use App\Entities\Audio;
-use App\Entities\Image;
-use App\Entities\Media;
+use App\Entities\Media\Audio;
+use App\Entities\Media\Chapters;
+use App\Entities\Media\Document;
+use App\Entities\Media\Image;
+use App\Entities\Media\Transcript;
+use App\Entities\Media\Video;
 use CodeIgniter\Database\ConnectionInterface;
 use CodeIgniter\Model;
 use CodeIgniter\Validation\ValidationInterface;
@@ -27,7 +30,7 @@ class MediaModel extends Model
     /**
      * @var string
      */
-    protected $returnType = Media::class;
+    protected $returnType = Document::class;
 
     /**
      * @var string[]
@@ -36,7 +39,7 @@ class MediaModel extends Model
         'id',
         'file_path',
         'file_size',
-        'file_content_type',
+        'file_mimetype',
         'file_metadata',
         'type',
         'description',
@@ -52,29 +55,36 @@ class MediaModel extends Model
      * @param ValidationInterface|null $validation Validation
      */
     public function __construct(
-        protected string $fileType,
+        protected string $fileType = 'document',
         ConnectionInterface &$db = null,
         ValidationInterface $validation = null
     ) {
+        // @phpstan-ignore-next-line
         switch ($fileType) {
             case 'audio':
                 $this->returnType = Audio::class;
                 break;
+            case 'video':
+                $this->returnType = Video::class;
+                break;
             case 'image':
                 $this->returnType = Image::class;
                 break;
+            case 'transcript':
+                $this->returnType = Transcript::class;
+                break;
+            case 'chapters':
+                $this->returnType = Chapters::class;
+                break;
             default:
-                // do nothing, keep Media class as default
+                // do nothing, keep Document class as default
                 break;
         }
 
         parent::__construct($db, $validation);
     }
 
-    /**
-     * @return Media|Image|Audio
-     */
-    public function getMediaById(int $mediaId): object
+    public function getMediaById(int $mediaId): Document | Audio | Video | Image | Transcript | Chapters
     {
         $cacheName = "media#{$mediaId}";
         if (! ($found = cache($cacheName))) {
@@ -94,7 +104,9 @@ class MediaModel extends Model
     }
 
     /**
-     * @param Media|Image|Audio $media
+     * @param Document|Audio|Video|Image|Transcript|Chapters $media
+     *
+     * @noRector ReturnTypeDeclarationRector
      */
     public function saveMedia(object $media): int | false
     {
@@ -103,7 +115,21 @@ class MediaModel extends Model
             return false;
         }
 
-        // @phpstan-ignore-next-line
         return $mediaId;
     }
+
+    /**
+     * @param Document|Audio|Video|Image|Transcript|Chapters $media
+     *
+     * @noRector ReturnTypeDeclarationRector
+     */
+    public function updateMedia(object $media): bool
+    {
+        return $this->update($media->id, $media);
+    }
+
+    public function deleteMedia(int $mediaId): bool
+    {
+        return $this->delete($mediaId, true);
+    }
 }
diff --git a/app/Models/MediaModelOLD.php b/app/Models/MediaModelOLD.php
deleted file mode 100644
index fcdc566006a3a3576c2f2d5a997aa2bc5d3dd04e..0000000000000000000000000000000000000000
--- a/app/Models/MediaModelOLD.php
+++ /dev/null
@@ -1,112 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Podlibre
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-namespace App\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 9e5688825e8065bb8524271734ef86041c8fc5e5..9a64585e87ee716dd9216f8d29fd0b4d5902ad2e 100644
--- a/app/Models/PersonModel.php
+++ b/app/Models/PersonModel.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 namespace App\Models;
 
-use App\Entities\Image;
 use App\Entities\Person;
 use CodeIgniter\Database\Query;
 use CodeIgniter\Model;
@@ -196,7 +195,7 @@ class PersonModel extends Model
             'full_name' => $fullName,
             'unique_name' => slugify($fullName),
             'information_url' => $informationUrl,
-            'image' => new Image(download_file($image)),
+            'image' => download_file($image),
             'created_by' => user_id(),
             'updated_by' => user_id(),
         ]);
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index 02a913bb2448c2049a757113a8da3876706a8bad..9b639d69d4b80bfa89021586cb8307ece6e5bf51 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -484,9 +484,9 @@ class PodcastModel extends Model
                 $actor->display_name = $podcast->title;
                 $actor->summary = $podcast->description_html;
                 $actor->avatar_image_url = $podcast->cover->federation_url;
-                $actor->avatar_image_mimetype = $podcast->cover->mimetype;
+                $actor->avatar_image_mimetype = $podcast->cover->file_mimetype;
                 $actor->cover_image_url = $podcast->banner->federation_url;
-                $actor->cover_image_mimetype = $podcast->banner->mimetype;
+                $actor->cover_image_mimetype = $podcast->banner->file_mimetype;
 
                 if ($actor->hasChanged()) {
                     $actorModel->update($actor->id, $actor);
diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php
index b0847e1c9d1742f620638f791a875aca82eb9fdf..637f84da1032ff020c71e2bbbe54d49c124bd6a8 100644
--- a/modules/Admin/Controllers/EpisodeController.php
+++ b/modules/Admin/Controllers/EpisodeController.php
@@ -12,15 +12,12 @@ namespace Modules\Admin\Controllers;
 
 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 CodeIgniter\Exceptions\PageNotFoundException;
@@ -133,7 +130,8 @@ class EpisodeController extends BaseController
             'title' => $this->request->getPost('title'),
             'slug' => $this->request->getPost('slug'),
             'guid' => null,
-            'audio_file' => $this->request->getFile('audio_file'),
+            'audio' => $this->request->getFile('audio_file'),
+            'cover' => $this->request->getFile('cover'),
             'description_markdown' => $this->request->getPost('description'),
             'location' => $this->request->getPost('location_name') === '' ? null : new Location($this->request->getPost(
                 'location_name'
@@ -161,69 +159,22 @@ class EpisodeController extends BaseController
         $db = db_connect();
         $db->transStart();
 
-        $coverFile = $this->request->getFile('cover');
-        if ($coverFile !== null && $coverFile->isValid()) {
-            $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');
-        if (
-            $transcriptChoice === 'upload-file'
-            && ($transcriptFile = $this->request->getFile('transcript_file'))
-            && $transcriptFile->isValid()
-        ) {
-            $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;
+        if ($transcriptChoice === 'upload-file') {
+            $newEpisode->setTranscript($this->request->getFile('transcript_file'));
         } elseif ($transcriptChoice === 'remote-url') {
             $newEpisode->transcript_remote_url = $this->request->getPost(
                 'transcript_remote_url'
-            ) === '' ? null : $this->request->getPost('transcript_file_remote_url');
+            ) === '' ? null : $this->request->getPost('transcript_remote_url');
         }
 
         $chaptersChoice = $this->request->getPost('chapters-choice');
-        if (
-            $chaptersChoice === 'upload-file'
-            && ($chaptersFile = $this->request->getFile('chapters_file'))
-            && $chaptersFile->isValid()
-        ) {
-            $newEpisode->chapters_file = $chaptersFile;
+        if ($chaptersChoice === 'upload-file') {
+            $newEpisode->setChapters($this->request->getFile('chapters_file'));
         } elseif ($chaptersChoice === 'remote-url') {
-            $newEpisode->chapters_file_remote_url = $this->request->getPost(
-                'chapters_file_remote_url'
-            ) === '' ? null : $this->request->getPost('chapters_file_remote_url');
+            $newEpisode->chapters_remote_url = $this->request->getPost(
+                'chapters_remote_url'
+            ) === '' ? null : $this->request->getPost('chapters_remote_url');
         }
 
         $episodeModel = new EpisodeModel();
@@ -310,51 +261,43 @@ class EpisodeController extends BaseController
         $this->episode->custom_rss_string = $this->request->getPost('custom_rss');
 
         $this->episode->updated_by = (int) user_id();
-
-        $audioFile = $this->request->getFile('audio_file');
-        if ($audioFile !== null && $audioFile->isValid()) {
-            $this->episode->audio_file = $audioFile;
-        }
-
-        $coverFile = $this->request->getFile('cover');
-        if ($coverFile !== null && $coverFile->isValid()) {
-            $this->episode->cover = new Image($coverFile);
-        }
+        $this->episode->setAudio($this->request->getFile('audio_file'));
+        $this->episode->setCover($this->request->getFile('cover'));
 
         $transcriptChoice = $this->request->getPost('transcript-choice');
         if ($transcriptChoice === 'upload-file') {
             $transcriptFile = $this->request->getFile('transcript_file');
             if ($transcriptFile !== null && $transcriptFile->isValid()) {
                 $this->episode->transcript_file = $transcriptFile;
-                $this->episode->transcript_file_remote_url = null;
+                $this->episode->transcript_remote_url = null;
             }
         } elseif ($transcriptChoice === 'remote-url') {
             if (
-                ($transcriptFileRemoteUrl = $this->request->getPost('transcript_file_remote_url')) &&
+                ($transcriptFileRemoteUrl = $this->request->getPost('transcript_remote_url')) &&
                 (($transcriptFile = $this->episode->transcript_file) !== null)
             ) {
                 unlink((string) $transcriptFile);
-                $this->episode->transcript_file_path = null;
+                $this->episode->transcript->file_path = null;
             }
-            $this->episode->transcript_file_remote_url = $transcriptFileRemoteUrl === '' ? null : $transcriptFileRemoteUrl;
+            $this->episode->transcript_remote_url = $transcriptFileRemoteUrl === '' ? null : $transcriptFileRemoteUrl;
         }
 
         $chaptersChoice = $this->request->getPost('chapters-choice');
         if ($chaptersChoice === 'upload-file') {
             $chaptersFile = $this->request->getFile('chapters_file');
             if ($chaptersFile !== null && $chaptersFile->isValid()) {
-                $this->episode->chapters_file = $chaptersFile;
-                $this->episode->chapters_file_remote_url = null;
+                $this->episode->chapters = $chaptersFile;
+                $this->episode->chapters_remote_url = null;
             }
         } elseif ($chaptersChoice === 'remote-url') {
             if (
-                ($chaptersFileRemoteUrl = $this->request->getPost('chapters_file_remote_url')) &&
+                ($chaptersFileRemoteUrl = $this->request->getPost('chapters_remote_url')) &&
                 (($chaptersFile = $this->episode->chapters_file) !== null)
             ) {
                 unlink((string) $chaptersFile);
-                $this->episode->chapters_file_path = null;
+                $this->episode->chapters->file_path = null;
             }
-            $this->episode->chapters_file_remote_url = $chaptersFileRemoteUrl === '' ? null : $chaptersFileRemoteUrl;
+            $this->episode->chapters_remote_url = $chaptersFileRemoteUrl === '' ? null : $chaptersFileRemoteUrl;
         }
 
         $db = db_connect();
@@ -396,7 +339,7 @@ class EpisodeController extends BaseController
     public function transcriptDelete(): RedirectResponse
     {
         unlink((string) $this->episode->transcript_file);
-        $this->episode->transcript_file_path = null;
+        $this->episode->transcript->file_path = null;
 
         $episodeModel = new EpisodeModel();
 
@@ -413,7 +356,7 @@ class EpisodeController extends BaseController
     public function chaptersDelete(): RedirectResponse
     {
         unlink((string) $this->episode->chapters_file);
-        $this->episode->chapters_file_path = null;
+        $this->episode->chapters->file_path = null;
 
         $episodeModel = new EpisodeModel();
 
diff --git a/modules/Admin/Controllers/PersonController.php b/modules/Admin/Controllers/PersonController.php
index 7825e6f8d3ea7b8b08772c727b9d29025f0e6f28..a2020c022857827468c35efd0b11cad45ec1c2df 100644
--- a/modules/Admin/Controllers/PersonController.php
+++ b/modules/Admin/Controllers/PersonController.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 namespace Modules\Admin\Controllers;
 
-use App\Entities\Image;
 use App\Entities\Person;
 use App\Models\PersonModel;
 use CodeIgniter\Exceptions\PageNotFoundException;
@@ -78,6 +77,7 @@ class PersonController extends BaseController
         }
 
         $person = new Person([
+            'avatar' => $this->request->getFile('avatar'),
             'full_name' => $this->request->getPost('full_name'),
             'unique_name' => $this->request->getPost('unique_name'),
             'information_url' => $this->request->getPost('information_url'),
@@ -85,11 +85,6 @@ class PersonController extends BaseController
             'updated_by' => user_id(),
         ]);
 
-        $avatarFile = $this->request->getFile('avatar');
-        if ($avatarFile !== null && $avatarFile->isValid()) {
-            $person->avatar = new Image($avatarFile);
-        }
-
         $personModel = new PersonModel();
 
         if (! $personModel->insert($person)) {
diff --git a/modules/Admin/Controllers/PodcastController.php b/modules/Admin/Controllers/PodcastController.php
index e3e0162e600a8be54aa9b94fb326e0818e80614d..09cf6693ab41bde26225701bc43ce372626fa44f 100644
--- a/modules/Admin/Controllers/PodcastController.php
+++ b/modules/Admin/Controllers/PodcastController.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 namespace Modules\Admin\Controllers;
 
-use App\Entities\Image;
 use App\Entities\Location;
 use App\Entities\Podcast;
 use App\Models\CategoryModel;
@@ -196,6 +195,8 @@ class PodcastController extends BaseController
         $newPodcast = new Podcast([
             'title' => $this->request->getPost('title'),
             'handle' => $this->request->getPost('handle'),
+            'cover' => $this->request->getFile('cover'),
+            'banner' => $this->request->getFile('banner'),
             'description_markdown' => $this->request->getPost('description'),
             'language_code' => $this->request->getPost('language'),
             'category_id' => $this->request->getPost('category'),
@@ -228,48 +229,6 @@ class PodcastController extends BaseController
         $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()) {
-            $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();
         if (! ($newPodcastId = $podcastModel->insert($newPodcast, true))) {
             $db->transRollback();
@@ -344,15 +303,9 @@ class PodcastController extends BaseController
 
         $this->podcast->title = $this->request->getPost('title');
         $this->podcast->description_markdown = $this->request->getPost('description');
+        $this->podcast->setCover($this->request->getFile('cover'));
+        $this->podcast->setBanner($this->request->getFile('banner'));
 
-        $coverFile = $this->request->getFile('cover');
-        if ($coverFile !== null && $coverFile->isValid()) {
-            $this->podcast->cover->setFile($coverFile);
-        }
-        $bannerFile = $this->request->getFile('banner');
-        if ($bannerFile !== null && $bannerFile->isValid()) {
-            $this->podcast->banner = new Image($bannerFile);
-        }
         $this->podcast->language_code = $this->request->getPost('language');
         $this->podcast->category_id = $this->request->getPost('category');
         $this->podcast->parental_advisory =
@@ -381,6 +334,7 @@ class PodcastController extends BaseController
         $this->podcast->updated_by = (int) user_id();
 
         $db = db_connect();
+
         $db->transStart();
 
         $podcastModel = new PodcastModel();
@@ -400,7 +354,7 @@ class PodcastController extends BaseController
 
         $db->transComplete();
 
-        return redirect()->route('podcast-view', [$this->podcast->id]);
+        return redirect()->back();
     }
 
     public function deleteBanner(): RedirectResponse
@@ -409,17 +363,14 @@ class PodcastController extends BaseController
             return redirect()->back();
         }
 
-        $this->podcast->banner->delete(config('Images')->podcastBannerSizes);
-
-        $this->podcast->banner_path = null;
-        $this->podcast->banner_mimetype = null;
+        $this->podcast->banner->deleteFile();
 
-        $podcastModel = new PodcastModel();
-        if (! $podcastModel->update($this->podcast->id, $this->podcast)) {
+        $mediaModel = new MediaModel();
+        if (! $mediaModel->deleteMedia((int) $this->podcast->banner_id)) {
             return redirect()
                 ->back()
                 ->withInput()
-                ->with('errors', $podcastModel->errors());
+                ->with('errors', $mediaModel->errors());
         }
 
         return redirect()->back();
diff --git a/modules/Admin/Controllers/PodcastImportController.php b/modules/Admin/Controllers/PodcastImportController.php
index cddb3989d07c8016a08a977ab598e31e8e805377..a990cc0a1d77c2fd8f80b1b6f7e6aeeedc2a33ba 100644
--- a/modules/Admin/Controllers/PodcastImportController.php
+++ b/modules/Admin/Controllers/PodcastImportController.php
@@ -11,7 +11,6 @@ declare(strict_types=1);
 namespace Modules\Admin\Controllers;
 
 use App\Entities\Episode;
-use App\Entities\Image;
 use App\Entities\Location;
 use App\Entities\Person;
 use App\Entities\Podcast;
diff --git a/modules/Admin/Language/en/Episode.php b/modules/Admin/Language/en/Episode.php
index 87552b95ee25b8ca625db9d5c84dd983acdc386d..ea510aade0469a1c51ea00e2ad65bbb911b47da1 100644
--- a/modules/Admin/Language/en/Episode.php
+++ b/modules/Admin/Language/en/Episode.php
@@ -91,12 +91,12 @@ return [
         'transcript' => 'Transcript or closed captions',
         'transcript_hint' => 'Allowed formats are txt, html, srt or json.',
         'transcript_file' => 'Transcript file',
-        'transcript_file_remote_url' => 'Remote url for transcript',
+        'transcript_remote_url' => 'Remote url for transcript',
         'transcript_file_delete' => 'Delete transcript file',
         'chapters' => 'Chapters',
         'chapters_hint' => 'File must be in JSON Chapters format.',
         'chapters_file' => 'Chapters file',
-        'chapters_file_remote_url' => 'Remote url for chapters file',
+        'chapters_remote_url' => 'Remote url for chapters file',
         'chapters_file_delete' => 'Delete chapters file',
         'advanced_section_title' => 'Advanced Parameters',
         'advanced_section_subtitle' =>
diff --git a/modules/Admin/Language/fr/Episode.php b/modules/Admin/Language/fr/Episode.php
index ad3cf9713f098bea38e1a8954527ef738f0b35b2..1c5cbe4414dea751f08d7a6cfa9ac387c348edbf 100644
--- a/modules/Admin/Language/fr/Episode.php
+++ b/modules/Admin/Language/fr/Episode.php
@@ -93,13 +93,13 @@ return [
         'transcript_hint' =>
             'Les formats autorisés sont txt, html, srt ou json.',
         'transcript_file' => 'Fichier de transcription',
-        'transcript_file_remote_url' =>
+        'transcript_remote_url' =>
             'URL distante pour le fichier de transcription',
         'transcript_file_delete' => 'Supprimer le fichier de transcription',
         'chapters' => 'Chapitrage',
         'chapters_hint' => 'Le fichier doit être en format “JSON Chapters”.',
         'chapters_file' => 'Fichier de chapitrage',
-        'chapters_file_remote_url' =>
+        'chapters_remote_url' =>
             'URL distante pour le fichier de chapitrage',
         'chapters_file_delete' => 'Supprimer le fichier de chapitrage',
         'advanced_section_title' => 'Paramètres avancés',
diff --git a/modules/Analytics/Helpers/analytics_helper.php b/modules/Analytics/Helpers/analytics_helper.php
index ae215d52b6f3d663f2490e4cbd4d9b4ed451109b..026e6554def8ddef3af21857efa28a75e52a17ac 100644
--- a/modules/Analytics/Helpers/analytics_helper.php
+++ b/modules/Analytics/Helpers/analytics_helper.php
@@ -59,8 +59,8 @@ if (! function_exists('generate_episode_analytics_url')) {
                     $podcastId,
                     $episodeId,
                     // bytes_threshold: number of bytes that must be downloaded for an episode to be counted in download analytics
-                    // - if file is shorter than 60sec, then it's audio_file_size
-                    // - if file is longer than 60 seconds then it's audio_file_header_size + 60 seconds
+                    // - if audio is less than or equal to 60s, then take the audio file_size
+                    // - if audio is more than 60s, then take the audio file_header_size + 60s
                     $audioFileDuration <= 60
                         ? $audioFileSize
                         : $audioFileHeaderSize +
diff --git a/phpstan.neon b/phpstan.neon
index 253465986b03f638d80ed7ab8b6c1c9800479d4e..f96db3c136d910b40d66b51033ac1aedc9b786a3 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -20,6 +20,7 @@ parameters:
     ignoreErrors:
         - '#This property type might be inlined to PHP. Do you have confidence it is correct\? Put it here#'
         - '#^Cognitive complexity for#'
+        - '#^Class cognitive complexity#'
         - '#Do not use chained method calls. Put each on separated lines.#'
         - '#Do not inherit from abstract class, better use composition#'
         - '#Cannot access property [\$a-z_]+ on ((array\|)?object)#'
@@ -28,7 +29,7 @@ parameters:
         - '#Function \"preg_.*\(\)\" cannot be used/left in the code#'
         - '#Function "property_exists\(\)" cannot be used/left in the code#'
         - '#Instead of "instanceof/is_a\(\)" use ReflectionProvider service or "\(new ObjectType\(<desired_type\>\)\)\-\>isSuperTypeOf\(<element_type\>\)" for static reflection to work#'
-        - '#^Access to an undefined property App\\Entities\\Image#'
+        - '#^Access to an undefined property App\\Entities\\Media\\Image#'
         -
             message: '#Function "function_exists\(\)" cannot be used/left in the code#'
             paths:
diff --git a/themes/cp_admin/episode/create.php b/themes/cp_admin/episode/create.php
index 41ac3e152cd0038b25a8fc4cb5e0c207f44e7483..93b92c702f64099c7d2387836d4033f89ee2facf 100644
--- a/themes/cp_admin/episode/create.php
+++ b/themes/cp_admin/episode/create.php
@@ -156,8 +156,8 @@
             <Forms.Input class="w-full" name="transcript_file" type="file" accept=".txt,.html,.srt,.json" />
         </section>
         <section id="transcript-file-remote-url" class="tab-panel">
-            <Forms.Label class="sr-only" for="transcript_file_remote_url" isOptional="true"><?= lang('Episode.form.transcript_file_remote_url') ?></Forms.Label>
-            <Forms.Input class="w-full" placeholder="https://…" name="transcript_file_remote_url" />
+            <Forms.Label class="sr-only" for="transcript_remote_url" isOptional="true"><?= lang('Episode.form.transcript_remote_url') ?></Forms.Label>
+            <Forms.Input class="w-full" placeholder="https://…" name="transcript_remote_url" />
         </section>
     </div>
 </div>
@@ -183,8 +183,8 @@
             <Forms.Input class="w-full" name="chapters_file" type="file" accept=".json" />
         </section>
         <section id="chapters-file-remote-url" class="tab-panel">
-            <Forms.Label class="sr-only" for="chapters_file_remote_url" isOptional="true"><?= lang('Episode.form.chapters_file_remote_url') ?></Forms.Label>
-            <Forms.Input class="w-full" placeholder="https://…" name="chapters_file_remote_url" />
+            <Forms.Label class="sr-only" for="chapters_remote_url" isOptional="true"><?= lang('Episode.form.chapters_remote_url') ?></Forms.Label>
+            <Forms.Input class="w-full" placeholder="https://…" name="chapters_remote_url" />
         </section>
     </div>
 </div>
diff --git a/themes/cp_admin/episode/edit.php b/themes/cp_admin/episode/edit.php
index d26d832e4d0f0e7c338343d2fd18eb3e8ae471bf..035b8d6352b539916ad3fb80be063ad112a8f5c5 100644
--- a/themes/cp_admin/episode/edit.php
+++ b/themes/cp_admin/episode/edit.php
@@ -153,10 +153,10 @@
             ')</small>' .
             hint_tooltip(lang('Episode.form.transcript_hint'), 'ml-1') ?></legend>
 <div class="form-input-tabs">
-    <input type="radio" name="transcript-choice" id="transcript-file-upload-choice" aria-controls="transcript-file-upload-choice" value="upload-file" <?= $episode->transcript_file_remote_url ? '' : 'checked' ?> />
+    <input type="radio" name="transcript-choice" id="transcript-file-upload-choice" aria-controls="transcript-file-upload-choice" value="upload-file" <?= $episode->transcript_remote_url ? '' : 'checked' ?> />
     <label for="transcript-file-upload-choice"><?= lang('Common.forms.upload_file') ?></label>
 
-    <input type="radio" name="transcript-choice" id="transcript-file-remote-url-choice" aria-controls="transcript-file-remote-url-choice" value="remote-url" <?= $episode->transcript_file_remote_url ? 'checked' : '' ?> />
+    <input type="radio" name="transcript-choice" id="transcript-file-remote-url-choice" aria-controls="transcript-file-remote-url-choice" value="remote-url" <?= $episode->transcript_remote_url ? 'checked' : '' ?> />
     <label for="transcript-file-remote-url-choice"><?= lang('Common.forms.remote_url') ?></label>
 
     <div class="py-2 tab-panels">
@@ -164,7 +164,7 @@
             <?php if ($episode->transcript_file) : ?>
                 <div class="flex mb-1 gap-x-2">
                     <?= anchor(
-                $episode->transcript_file_url,
+                $episode->transcript->file_url,
                 icon('file', 'mr-2 text-skin-muted') .
                             $episode->transcript_file,
                 [
@@ -195,8 +195,8 @@
             <Forms.Input class="w-full" name="transcript_file" type="file" accept=".txt,.html,.srt,.json" />
         </section>
         <section id="transcript-file-remote-url" class="tab-panel">
-            <Forms.Label class="sr-only" for="transcript_file_remote_url" isOptional="true"><?= lang('Episode.form.transcript_file_remote_url') ?></Forms.Label>
-            <Forms.Input class="w-full" placeholder="https://…" name="transcript_file_remote_url" value="<?= $episode->transcript_file_remote_url ?>" />
+            <Forms.Label class="sr-only" for="transcript_remote_url" isOptional="true"><?= lang('Episode.form.transcript_remote_url') ?></Forms.Label>
+            <Forms.Input class="w-full" placeholder="https://…" name="transcript_remote_url" value="<?= $episode->transcript_remote_url ?>" />
         </section>
     </div>
 </div>
@@ -210,10 +210,10 @@
             ')</small>' .
             hint_tooltip(lang('Episode.form.chapters_hint'), 'ml-1') ?></legend>
 <div class="form-input-tabs">
-    <input type="radio" name="chapters-choice" id="chapters-file-upload-choice" aria-controls="chapters-file-upload-choice" value="upload-file" <?= $episode->chapters_file_remote_url ? '' : 'checked' ?> />
+    <input type="radio" name="chapters-choice" id="chapters-file-upload-choice" aria-controls="chapters-file-upload-choice" value="upload-file" <?= $episode->chapters_remote_url ? '' : 'checked' ?> />
     <label for="chapters-file-upload-choice"><?= lang('Common.forms.upload_file') ?></label>
 
-    <input type="radio" name="chapters-choice" id="chapters-file-remote-url-choice" aria-controls="chapters-file-remote-url-choice" value="remote-url" <?= $episode->chapters_file_remote_url ? 'checked' : '' ?> />
+    <input type="radio" name="chapters-choice" id="chapters-file-remote-url-choice" aria-controls="chapters-file-remote-url-choice" value="remote-url" <?= $episode->chapters_remote_url ? 'checked' : '' ?> />
     <label for="chapters-file-remote-url-choice"><?= lang('Common.forms.remote_url') ?></label>
 
     <div class="py-2 tab-panels">
@@ -221,7 +221,7 @@
             <?php if ($episode->chapters_file) : ?>
                 <div class="flex mb-1 gap-x-2">
                     <?= anchor(
-                $episode->chapters_file_url,
+                $episode->chapters->file_url,
                 icon('file', 'mr-2') . $episode->chapters_file,
                 [
                     'class' => 'inline-flex items-center text-xs',
@@ -251,8 +251,8 @@
             <Forms.Input class="w-full" name="chapters_file" type="file" accept=".json" />
         </section>
         <section id="chapters-file-remote-url" class="tab-panel">
-            <Forms.Label class="sr-only" for="chapters_file_remote_url" isOptional="true"><?= lang('Episode.form.chapters_file_remote_url') ?></Forms.Label>
-            <Forms.Input class="w-full" placeholder="https://…" name="chapters_file_remote_url" value="<?= $episode->chapters_file_remote_url ?>" />
+            <Forms.Label class="sr-only" for="chapters_remote_url" isOptional="true"><?= lang('Episode.form.chapters_remote_url') ?></Forms.Label>
+            <Forms.Input class="w-full" placeholder="https://…" name="chapters_remote_url" value="<?= $episode->chapters_remote_url ?>" />
         </section>
     </div>
 </div>
diff --git a/themes/cp_admin/episode/publish.php b/themes/cp_admin/episode/publish.php
index f3e99324103a7815847d3418621d05819aafe655..eacfa9dbb02d6960fdbbc41bc9f09f80996f0264 100644
--- a/themes/cp_admin/episode/publish.php
+++ b/themes/cp_admin/episode/publish.php
@@ -59,7 +59,7 @@
                     </time>
                 </div>
             </a>
-            <?= audio_player($episode->audio->file_url, $episode->audio->file_content_type, 'mt-auto') ?>
+            <?= audio_player($episode->audio->file_url, $episode->audio->file_mimetype, '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 8eb15701c172c2d74745b2ab1a19d2e8ffded5a4..80b2860d53d681569adaa1e3487de2ed5761e52d 100644
--- a/themes/cp_admin/episode/publish_edit.php
+++ b/themes/cp_admin/episode/publish_edit.php
@@ -63,7 +63,7 @@
                     </time>
                 </div>
             </a>
-            <?= audio_player($episode->audio->file_url, $episode->audio->file_content_type, 'mt-auto') ?>
+            <?= audio_player($episode->audio->file_url, $episode->audio->file_mimetype, '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 425c989962ac9314601faeb0bb35c4d4ddbeae65..ec42a99f3c9dd5a9488de18e0194968ff81f525f 100644
--- a/themes/cp_admin/episode/soundbites.php
+++ b/themes/cp_admin/episode/soundbites.php
@@ -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_content_type ?>">
+            <source src="<?= $episode->audio->file_url ?>" type="<?= $episode->audio->file_mimetype ?>">
             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 5310bce7665f0fbad5a2ab392fa454e7cea0e1bb..f9a9e5e84b552bb6cdf124749ed45b6f1c2891aa 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_content_type) ?>
+    <?= audio_player($episode->audio->file_url, $episode->audio->file_mimetype) ?>
 </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 45651946c36ae68d9fa9cbc2ae20a70c9a9860ad..eb03dc58ffa2edd16deec449f7aa08c535b39c36 100644
--- a/themes/cp_admin/podcast/edit.php
+++ b/themes/cp_admin/podcast/edit.php
@@ -25,7 +25,7 @@
     <?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" />
+    <img src="<?= $podcast->banner->small_url ?>" alt="" class="w-full aspect-[3/1] bg-header" />
     <div class="flex px-4 py-2">
         <img src="<?= $podcast->cover->thumbnail_url ?>" alt="<?= $podcast->title ?>"
             class="w-16 h-16 mr-4 -mt-8 rounded-full ring-2 ring-background-elevated aspect-square" />
diff --git a/themes/cp_app/embed.php b/themes/cp_app/embed.php
index 7b49fae61c1f15973018f49cbf77eb0ab49ae694..55b3da0dc81b5ae3ce8de1df06d755107c73160e 100644
--- a/themes/cp_app/embed.php
+++ b/themes/cp_app/embed.php
@@ -46,7 +46,7 @@
                         ? '?_from=' .
                             parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST)
                         : '') ?>
-                <source src="<?= $source ?>" type="<?= $episode->audio->file_content_type ?>" />
+                <source src="<?= $source ?>" type="<?= $episode->audio->file_mimetype ?>" />
             </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 80cb7e1350f6dd83f22a93b8bc81d322a95f3b18..b757aeca7001c2e7b6adb348bbabfda401e3b1f7 100644
--- a/themes/cp_app/episode/_layout.php
+++ b/themes/cp_app/episode/_layout.php
@@ -115,7 +115,7 @@
                 title="<?= $episode->title ?>"
                 podcast="<?= $episode->podcast->title ?>"
                 src="<?= $episode->audio_file_web_url ?>"
-                mediaType="<?= $episode->audio->file_content_type ?>"
+                mediaType="<?= $episode->audio->file_mimetype ?>"
                 playLabel="<?= lang('Common.play_episode_button.play') ?>"
                 playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button>
             <div class="text-xs">
diff --git a/themes/cp_app/episode/_partials/card.php b/themes/cp_app/episode/_partials/card.php
index 1105015ddde91511dcfee6662bc88aa609c8c72c..3b29f8ec6f4d5bfee7234c7b4651e969481cf9af 100644
--- a/themes/cp_app/episode/_partials/card.php
+++ b/themes/cp_app/episode/_partials/card.php
@@ -20,7 +20,7 @@
             title="<?= $episode->title ?>"
             podcast="<?= $episode->podcast->title ?>"
             src="<?= $episode->audio_file_web_url ?>"
-            mediaType="<?= $episode->audio->file_content_type ?>"
+            mediaType="<?= $episode->audio->file_mimetype ?>"
             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 d9b97d8043723f0faab6135a72be63b5a93575ee..9e69012d9d142394be0e1abfeb4127eb6f0d3059 100644
--- a/themes/cp_app/episode/_partials/preview_card.php
+++ b/themes/cp_app/episode/_partials/preview_card.php
@@ -21,7 +21,7 @@
         title="<?= $episode->title ?>"
         podcast="<?= $episode->podcast->title ?>"
         src="<?= $episode->audio_file_web_url ?>"
-        mediaType="<?= $episode->audio->file_content_type ?>"
+        mediaType="<?= $episode->audio->file_mimetype ?>"
         playLabel="<?= lang('Common.play_episode_button.play') ?>"
         playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button>
 </div>
\ No newline at end of file
diff --git a/themes/cp_app/podcast/follow.php b/themes/cp_app/podcast/follow.php
index 10032df74a6005b3b5b8035b203bee3d2c34ec4b..e17cbd90d20ea441db83f6390474cd0195e7d10a 100644
--- a/themes/cp_app/podcast/follow.php
+++ b/themes/cp_app/podcast/follow.php
@@ -38,7 +38,7 @@
             'Fediverse.follow.subtitle',
         ) ?></h1>
         <div class="flex flex-col w-full max-w-xs -mt-24 overflow-hidden shadow bg-elevated rounded-xl">
-            <img src="<?= $actor->podcast->banner->small_url ?>" alt="" class="object-cover w-full aspect-[3/1] bg-header" />
+            <img src="<?= $actor->podcast->banner->small_url ?>" alt="" class="w-full aspect-[3/1] bg-header" />
             <div class="flex px-4 py-2">
                 <img src="<?= $actor->avatar_image_url ?>" alt="<?= $actor->display_name ?>"
                     class="w-16 h-16 mr-4 -mt-8 rounded-full ring-2 ring-background-elevated aspect-square" />