From 4651d01a84ff3ea8433a8ae26cfd750a1ec9e88d Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Fri, 12 Jun 2020 19:31:10 +0000
Subject: [PATCH] feat: write id3v2 tags to episode's audio file

- add $mediaRoot parameter in app config
- add and refactor helpers : id3, media and url
- add basic vscode settings for code formatting in devcontainer.json
- set post_max_size to Dockerfile
---
 .devcontainer/Dockerfile                      |   4 -
 .devcontainer/devcontainer.json               |  10 +-
 Dockerfile                                    |   5 +-
 app/Config/App.php                            |   8 ++
 app/Config/Cache.php                          |   4 +-
 app/Controllers/Episodes.php                  |  52 ++++++---
 app/Controllers/Podcasts.php                  |  14 +--
 .../2020-06-05-170000_add_episodes.php        |   8 +-
 app/Entities/Episode.php                      |   2 +-
 app/Entities/Podcast.php                      |   5 +-
 app/Helpers/file_helper.php                   |  53 ---------
 app/Helpers/id3_helper.php                    | 103 ++++++++++++++++++
 app/Helpers/media_helper.php                  |  46 ++++++++
 app/Helpers/url_helper.php                    |  18 +++
 app/Models/EpisodeModel.php                   |   2 +-
 app/Views/episodes/create.php                 |   3 +-
 app/Views/episodes/view.php                   |   6 +-
 app/Views/home.php                            |   6 +-
 app/Views/podcasts/view.php                   |  18 ++-
 19 files changed, 264 insertions(+), 103 deletions(-)
 delete mode 100644 app/Helpers/file_helper.php
 create mode 100644 app/Helpers/id3_helper.php
 create mode 100644 app/Helpers/media_helper.php
 create mode 100644 app/Helpers/url_helper.php

diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 1bd8905cbd..44fa834e03 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,9 +1,5 @@
 FROM php:latest
 
-RUN apt-get update && apt-get install -y \
-    libicu-dev \
-    && docker-php-ext-install intl
-
 COPY --from=composer /usr/bin/composer /usr/bin/composer
 
 RUN curl -sL https://deb.nodesource.com/setup_12.x | bash -
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 869ffed789..1a241bf61b 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -4,7 +4,15 @@
   "name": "Existing Dockerfile",
   "dockerFile": "./Dockerfile",
   "settings": {
-    "terminal.integrated.shell.linux": null
+    "terminal.integrated.shell.linux": "/bin/bash",
+    "editor.formatOnSave": true,
+    "editor.codeActionsOnSave": {
+      "source.organizeImports": true
+    },
+    "[php]": {
+      "editor.defaultFormatter": "esbenp.prettier-vscode"
+    },
+    "color-highlight.markerType": "dot-before"
   },
   "extensions": [
     "mikestead.dotenv",
diff --git a/Dockerfile b/Dockerfile
index 4c81d5f65c..279e67839a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,11 +9,14 @@ WORKDIR /castopod
 # Install intl extension using https://github.com/mlocati/docker-php-extension-installer
 RUN apt-get update && apt-get install -y \
     libicu-dev \
-    && docker-php-ext-install intl
+    libpng-dev \
+    zlib1g-dev \
+    && docker-php-ext-install intl gd
 
 RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli
 
 RUN echo "file_uploads = On\n" \
          "memory_limit = 100M\n" \
          "upload_max_filesize = 100M\n" \
+         "post_max_size = 120M\n" \
          > /usr/local/etc/php/conf.d/uploads.ini
diff --git a/app/Config/App.php b/app/Config/App.php
index cc58ca46dd..6bb6cb457e 100644
--- a/app/Config/App.php
+++ b/app/Config/App.php
@@ -266,4 +266,12 @@ class App extends BaseConfig
 	|   - http://www.w3.org/TR/CSP/
 	*/
     public $CSPEnabled = false;
+
+    /*
+	|--------------------------------------------------------------------------
+	| Media root folder
+	|--------------------------------------------------------------------------
+	| Defines the root folder for media files storage
+	*/
+    public $mediaRoot = 'media';
 }
diff --git a/app/Config/Cache.php b/app/Config/Cache.php
index 4ac1bf6d01..d743c58a89 100644
--- a/app/Config/Cache.php
+++ b/app/Config/Cache.php
@@ -1,4 +1,6 @@
-<?php namespace Config;
+<?php
+
+namespace Config;
 
 use CodeIgniter\Config\BaseConfig;
 
diff --git a/app/Controllers/Episodes.php b/app/Controllers/Episodes.php
index a3527ece2f..df0d1cb1d9 100644
--- a/app/Controllers/Episodes.php
+++ b/app/Controllers/Episodes.php
@@ -14,7 +14,7 @@ class Episodes extends BaseController
 {
     public function create($podcast_slug)
     {
-        helper(['form', 'database', 'file']);
+        helper(['form', 'database', 'media', 'id3']);
 
         $episode_model = new EpisodeModel();
         $podcast_model = new PodcastModel();
@@ -28,6 +28,7 @@ class Episodes extends BaseController
                 'title' => 'required',
                 'slug' => 'required',
                 'description' => 'required',
+                'type' => 'required',
             ])
         ) {
             $data = [
@@ -43,27 +44,40 @@ class Episodes extends BaseController
             $episode_slug = $this->request->getVar('slug');
 
             $episode_file = $this->request->getFile('episode_file');
-            $episode_file_metadata = get_file_metadata($episode_file);
-            $episode_file_name =
-                $episode_slug . '.' . $episode_file->getExtension();
-            $episode_path = save_podcast_media(
-                $episode_file,
-                $podcast_name,
-                $episode_file_name
-            );
+            $episode_file_metadata = get_file_tags($episode_file);
 
             $image = $this->request->getFile('image');
-            $image_path = '';
+
+            // By default, the episode's image path is set to the podcast's
+            $image_path = $podcast->image;
+
+            // check whether the user has inputted an image and store it
             if ($image->isValid()) {
-                $image_name = $episode_slug . '.' . $image->getExtension();
                 $image_path = save_podcast_media(
                     $image,
                     $podcast_name,
-                    $image_name
+                    $episode_slug
+                );
+            } elseif ($APICdata = $episode_file_metadata['attached_picture']) {
+                // if the user didn't input an image,
+                // check if the uploaded audio file has an attached cover and store it
+                $cover_image = new \CodeIgniter\Files\File('episode_cover');
+                file_put_contents($cover_image, $APICdata);
+
+                $image_path = save_podcast_media(
+                    $cover_image,
+                    $podcast_name,
+                    $episode_slug
                 );
             }
 
-            $episode_model->save([
+            $episode_path = save_podcast_media(
+                $episode_file,
+                $podcast_name,
+                $episode_slug
+            );
+
+            $episode = new \App\Entities\Episode([
                 'podcast_id' => $podcast->id,
                 'title' => $this->request->getVar('title'),
                 'slug' => $episode_slug,
@@ -76,14 +90,18 @@ class Episodes extends BaseController
                 'duration' => $episode_file_metadata['playtime_seconds'],
                 'image' => $image_path,
                 'explicit' => $this->request->getVar('explicit') or false,
-                'episode_number' =>
-                    $this->request->getVar('episode_number') or null,
-                'season_number' =>
-                    $this->request->getVar('season_number') or null,
+                'number' => $this->request->getVar('episode_number'),
+                'season_number' => $this->request->getVar('season_number')
+                    ? $this->request->getVar('season_number')
+                    : null,
                 'type' => $this->request->getVar('type'),
                 'block' => $this->request->getVar('block') or false,
             ]);
 
+            $episode_model->save($episode);
+
+            $episode_file = write_file_tags($podcast, $episode);
+
             return redirect()->to(
                 base_url(
                     route_to(
diff --git a/app/Controllers/Podcasts.php b/app/Controllers/Podcasts.php
index f118a163aa..e190eb28bc 100644
--- a/app/Controllers/Podcasts.php
+++ b/app/Controllers/Podcasts.php
@@ -7,6 +7,7 @@
 
 namespace App\Controllers;
 
+use App\Entities\Podcast;
 use App\Models\CategoryModel;
 use App\Models\EpisodeModel;
 use App\Models\LanguageModel;
@@ -16,7 +17,7 @@ class Podcasts extends BaseController
 {
     public function create()
     {
-        helper(['form', 'database', 'file', 'misc']);
+        helper(['form', 'database', 'media', 'misc']);
         $podcast_model = new PodcastModel();
 
         if (
@@ -48,14 +49,9 @@ class Podcasts extends BaseController
         } else {
             $image = $this->request->getFile('image');
             $podcast_name = $this->request->getVar('name');
-            $image_name = 'cover.' . $image->getExtension();
-            $image_path = save_podcast_media(
-                $image,
-                $podcast_name,
-                $image_name
-            );
+            $image_path = save_podcast_media($image, $podcast_name, 'cover');
 
-            $podcast_model->save([
+            $podcast = new Podcast([
                 'title' => $this->request->getVar('title'),
                 'name' => $podcast_name,
                 'description' => $this->request->getVar('description'),
@@ -78,6 +74,8 @@ class Podcasts extends BaseController
                 ),
             ]);
 
+            $podcast_model->save($podcast);
+
             return redirect()->to(
                 base_url(route_to('podcasts_view', '@' . $podcast_name))
             );
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 35a9d7322d..e6363dc529 100644
--- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php
+++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
@@ -97,11 +97,10 @@ class AddEpisodes extends Migration
                 'comment' =>
                     'The episode parental advisory information. Where the explicit value can be one of the following: true. If you specify true, indicating the presence of explicit content, Apple Podcasts displays an Explicit parental advisory graphic for your episode.     Episodes containing explicit material aren’t available in some Apple Podcasts territories.     false. If you specify false, indicating that the episode does not contain explicit language or adult content, Apple Podcasts displays a Clean parental advisory graphic for your episode.',
             ],
-            'episode_number' => [
+            'number' => [
                 'type' => 'INT',
                 'constraint' => 10,
                 'unsigned' => true,
-                'null' => true,
                 'comment' =>
                     'An episode number. If all your episodes have numbers and you would like them to be ordered based on them use this tag for each one. Episode numbers are optional for <itunes:type> episodic shows, but are mandatory for serial shows. Where episode is a non-zero integer (1, 2, 3, etc.) representing your episode number.',
             ],
@@ -136,6 +135,11 @@ class AddEpisodes extends Migration
         ]);
         $this->forge->addKey('id', true);
         $this->forge->addUniqueKey(['podcast_id', 'slug']);
+
+        // FIXME: as season_number can be null, the unique key constraint is useless when it is
+        // the majority of RDBMS behave that way
+        // possible solution: remove the null constraint on the season_number and set a default
+        $this->forge->addUniqueKey(['podcast_id', 'season_number', 'number']);
         $this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
         $this->forge->createTable('episodes');
     }
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index 1593da5e43..48ac268d6f 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -23,7 +23,7 @@ class Episode extends Entity
         'duration' => 'integer',
         'image' => 'string',
         'explicit' => 'boolean',
-        'episode_number' => 'integer',
+        'number' => 'integer',
         'season_number' => '?integer',
         'type' => 'string',
         'block' => 'boolean',
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 28034b9fd2..4bbd6de60e 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -12,13 +12,13 @@ use CodeIgniter\Entity;
 class Podcast extends Entity
 {
     protected $casts = [
+        'id' => 'integer',
         'title' => 'string',
         'name' => 'string',
         'description' => 'string',
-        'episode_description_footer' => '?string',
         'image' => 'string',
         'language' => 'string',
-        'category' => 'array',
+        'category' => 'string',
         'explicit' => 'boolean',
         'author' => '?string',
         'owner_name' => '?string',
@@ -27,6 +27,7 @@ class Podcast extends Entity
         'copyright' => '?string',
         'block' => 'boolean',
         'complete' => 'boolean',
+        'episode_description_footer' => '?string',
         'custom_html_head' => '?string',
     ];
 }
diff --git a/app/Helpers/file_helper.php b/app/Helpers/file_helper.php
deleted file mode 100644
index 6b9e313354..0000000000
--- a/app/Helpers/file_helper.php
+++ /dev/null
@@ -1,53 +0,0 @@
-<?php
-/**
- * @copyright  2020 Podlibre
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-use JamesHeinrich\GetID3\GetID3;
-
-/**
- * Saves a file to the corresponding podcast folder in `public/media`
- *
- * @param UploadedFile $file
- * @param string $podcast_name
- * @param string $file_name
- *
- * @return string The absolute path of the file
- */
-function save_podcast_media($file, $podcast_name, $file_name)
-{
-    $image_storage_folder = 'media/' . $podcast_name . '/';
-
-    // overwrite file if already existing
-    $file->move($image_storage_folder, $file_name, true);
-
-    return $image_storage_folder . $file_name;
-}
-
-/**
- * Gets audio file metadata and ID3 info
- *
- * @param UploadedFile $file
- *
- * @return array
- */
-function get_file_metadata($file)
-{
-    if (!$file->isValid()) {
-        throw new RuntimeException(
-            $file->getErrorString() . '(' . $file->getError() . ')'
-        );
-    }
-
-    $getID3 = new GetID3();
-    $FileInfo = $getID3->analyze($file);
-
-    return [
-        'cover_picture' => $FileInfo['comments']['picture'][0]['data'],
-        'filesize' => $FileInfo['filesize'],
-        'mime_type' => $FileInfo['mime_type'],
-        'playtime_seconds' => $FileInfo['playtime_seconds'],
-    ];
-}
diff --git a/app/Helpers/id3_helper.php b/app/Helpers/id3_helper.php
new file mode 100644
index 0000000000..0c5ade85f3
--- /dev/null
+++ b/app/Helpers/id3_helper.php
@@ -0,0 +1,103 @@
+<?php
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+use JamesHeinrich\GetID3\GetID3;
+use JamesHeinrich\GetID3\WriteTags;
+
+/**
+ * Gets audio file metadata and ID3 info
+ *
+ * @param UploadedFile $file
+ *
+ * @return array
+ */
+function get_file_tags($file)
+{
+    $getID3 = new GetID3();
+    $FileInfo = $getID3->analyze($file);
+
+    return [
+        'filesize' => $FileInfo['filesize'],
+        'mime_type' => $FileInfo['mime_type'],
+        'playtime_seconds' => $FileInfo['playtime_seconds'],
+        'attached_picture' => array_key_exists('comments', $FileInfo)
+            ? $FileInfo['comments']['picture'][0]['data']
+            : null,
+    ];
+}
+
+/**
+ * Write audio file metadata / ID3 tags
+ *
+ * @param App\Entities\Podcast $podcast
+ * @param App\Entities\Episode $episode
+ *
+ * @return UploadedFile
+ */
+function write_file_tags($podcast, $episode)
+{
+    $TextEncoding = 'UTF-8';
+
+    // Initialize getID3 tag-writing module
+    $tagwriter = new WriteTags();
+    $tagwriter->filename = media_path($episode->enclosure_url);
+
+    // set various options (optional)
+    $tagwriter->tagformats = ['id3v2.4'];
+    $tagwriter->tag_encoding = $TextEncoding;
+
+    $cover = new \CodeIgniter\Files\File(media_path($episode->image));
+
+    $APICdata = file_get_contents($cover->getRealPath());
+
+    // TODO: variables used for podcast specific tags
+    // $podcast_url = base_url('@' . $podcast->name);
+    // $podcast_feed_url = base_url('@' . $podcast->name . '/feed.xml');
+    // $episode_media_url = media_url($podcast->name . '/' . $episode->slug);
+
+    // populate data array
+    $TagData = [
+        'title' => [$episode->title],
+        'artist' => [$podcast->author],
+        'album' => [$podcast->title],
+        'year' => [$episode->pub_date->format('Y')],
+        'genre' => ['Podcast'],
+        'comment' => [$episode->description],
+        'track_number' => [strval($episode->number)],
+        'copyright_message' => [$podcast->copyright],
+        'publisher' => ['Podlibre'],
+        'encoded_by' => ['Castopod'],
+
+        // TODO: find a way to add the remaining tags for podcasts as the library doesn't seem to allow it
+        // 'website' => [$podcast_url],
+        // 'podcast' => [],
+        // 'podcast_identifier' => [$episode_media_url],
+        // 'podcast_feed' => [$podcast_feed_url],
+        // 'podcast_description' => [$podcast->description],
+    ];
+
+    $TagData['attached_picture'][] = [
+        'picturetypeid' => 2, // Cover. More: module.tag.id3v2.php
+        'data' => $APICdata,
+        'description' => 'cover',
+        'mime' => $cover->getMimeType(),
+    ];
+
+    $tagwriter->tag_data = $TagData;
+
+    // write tags
+    if ($tagwriter->WriteTags()) {
+        echo 'Successfully wrote tags<br>';
+        if (!empty($tagwriter->warnings)) {
+            echo 'There were some warnings:<br>' .
+                implode('<br><br>', $tagwriter->warnings);
+        }
+    } else {
+        echo 'Failed to write tags!<br>' .
+            implode('<br><br>', $tagwriter->errors);
+    }
+}
diff --git a/app/Helpers/media_helper.php b/app/Helpers/media_helper.php
new file mode 100644
index 0000000000..32845057ee
--- /dev/null
+++ b/app/Helpers/media_helper.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+/**
+ * Saves a file to the corresponding podcast folder in `public/media`
+ *
+ * @param UploadedFile $file
+ * @param string $podcast_name
+ * @param string $file_name
+ *
+ * @return string The absolute path of the file in media root
+ */
+function save_podcast_media($file, $podcast_name, $media_name)
+{
+    $file_name = $media_name . '.' . $file->guessExtension();
+
+    // move to media folder and overwrite file if already existing
+    $file->move(
+        config('App')->mediaRoot . '/' . $podcast_name . '/',
+        $file_name,
+        true
+    );
+
+    return $podcast_name . '/' . $file_name;
+}
+
+/**
+ * Prefixes the root media path to a given uri
+ *
+ * @param  mixed  $uri      URI string or array of URI segments
+ * @return string
+ */
+function media_path($uri = ''): string
+{
+    // convert segment array to string
+    if (is_array($uri)) {
+        $uri = implode('/', $uri);
+    }
+    $uri = trim($uri, '/');
+
+    return config('App')->mediaRoot . '/' . $uri;
+}
diff --git a/app/Helpers/url_helper.php b/app/Helpers/url_helper.php
new file mode 100644
index 0000000000..5b3ca930e4
--- /dev/null
+++ b/app/Helpers/url_helper.php
@@ -0,0 +1,18 @@
+<?php
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+/**
+ * Return the media base URL to use in views
+ *
+ * @param  mixed  $uri      URI string or array of URI segments
+ * @param  string $protocol
+ * @return string
+ */
+function media_url($uri = '', string $protocol = null): string
+{
+    return base_url(config('App')->mediaRoot . '/' . $uri, $protocol);
+}
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index 22c9b4dc8c..268d7c4bb4 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -27,7 +27,7 @@ class EpisodeModel extends Model
         'duration',
         'image',
         'explicit',
-        'episode_number',
+        'number',
         'season_number',
         'type',
         'block',
diff --git a/app/Views/episodes/create.php b/app/Views/episodes/create.php
index 4015d28c89..1f23d23490 100644
--- a/app/Views/episodes/create.php
+++ b/app/Views/episodes/create.php
@@ -50,8 +50,7 @@
     <label for="episode_number"><?= lang(
         'Episodes.form.episode_number'
     ) ?></label>
-    <input type="number" class="form-input" id="episode_number" name="episode_number"
-    <?= $podcast->type == 'serial' ? 'required' : '' ?> />
+    <input type="number" class="form-input" id="episode_number" name="episode_number" required />
 </div>
 
 <div class="flex flex-col mb-4">
diff --git a/app/Views/episodes/view.php b/app/Views/episodes/view.php
index 345bd29363..3f9f8073ee 100644
--- a/app/Views/episodes/view.php
+++ b/app/Views/episodes/view.php
@@ -2,12 +2,12 @@
 
 <?= $this->section('content') ?>
 
-<h1 class="text-xl"><?= $episode->title ?></h1>
-<img src="<?= base_url(
+<h1 class="text-2xl font-semibold"><?= $episode->title ?></h1>
+<img src="<?= media_url(
     $episode->image ? $episode->image : $podcast->image
 ) ?>" alt="Episode cover"  class="object-cover w-40 h-40 mb-6" />
 <audio controls>
-  <source src="<?= base_url(
+  <source src="<?= media_url(
       $episode->enclosure_url
   ) ?>" type="<?= $episode->enclosure_type ?>">
   Your browser does not support the audio tag.
diff --git a/app/Views/home.php b/app/Views/home.php
index a0587247da..c9e242a762 100644
--- a/app/Views/home.php
+++ b/app/Views/home.php
@@ -10,7 +10,11 @@
         <?php foreach ($podcasts as $podcast): ?>
             <a href="<?= route_to('podcasts_view', '@' . $podcast->name) ?>">
                 <article class="w-48 p-2 mb-4 mr-4 border shadow-sm hover:bg-gray-100 hover:shadow">
-                    <img alt="<?= $podcast->title ?>" src="<?= $podcast->image ?>" class="object-cover w-full h-40 mb-2" />
+                    <img alt="<?= $podcast->title ?>"
+                        src="<?= media_url(
+                            $podcast->image
+                        ) ?>" class="object-cover w-full h-40 mb-2"
+                    />
                     <h2 class="font-semibold leading-tight"><?= $podcast->title ?></h2>
                     <p class="text-gray-600">@<?= $podcast->name ?></p>
                 </article>
diff --git a/app/Views/podcasts/view.php b/app/Views/podcasts/view.php
index ae35991cee..e97939d089 100644
--- a/app/Views/podcasts/view.php
+++ b/app/Views/podcasts/view.php
@@ -3,7 +3,7 @@
 <?= $this->section('content') ?>
 <header class="py-4 border-b">
     <h1 class="text-2xl"><?= $podcast->title ?></h1>
-    <img src="<?= base_url(
+    <img src="<?= media_url(
         $podcast->image
     ) ?>" alt="Podcast cover" class="w-40 h-40 mb-6" />
 <a class="inline-flex px-4 py-2 border hover:bg-gray-100" href="<?= route_to(
@@ -19,19 +19,25 @@
     <?php if ($episodes): ?>
         <?php foreach ($episodes as $episode): ?>
             <article class="flex w-full max-w-lg p-4 mb-4 border shadow">
-                <img src="<?= base_url(
+                <img src="<?= media_url(
                     $episode->image ? $episode->image : $podcast->image
-                ) ?>" alt="<?= $episode->title ?>" class="w-32 h-32 mr-4" />
+                ) ?>" alt="<?= $episode->title ?>" class="object-cover w-32 h-32 mr-4" />
                 <div class="flex flex-col flex-1">
                     <a href="<?= route_to(
                         'episodes_view',
                         '@' . $podcast->name,
                         $episode->slug
                     ) ?>">
-                        <h3 class="text-xl font-semibold underline hover:no-underline"><?= $episode->title ?></h3>
-                    </a>    
+                        <h3 class="text-xl font-semibold">
+                            <span class="mr-1 underline hover:no-underline"><?= $episode->title ?></span>
+                            <span class="text-base font-bold text-gray-600">#<?= $episode->number ?></span>
+                        </h3>
+                        <p><?= $episode->description ?></p>
+                    </a>
                     <audio controls class="mt-auto">
-                        <source src="<?= $episode->enclosure_url ?>" type="<?= $episode->enclosure_type ?>">
+                        <source src="<?= media_url(
+                            $episode->enclosure_url
+                        ) ?>" type="<?= $episode->enclosure_type ?>">
                         Your browser does not support the audio tag.
                     </audio>
                 </div>
-- 
GitLab