From 3234500e2d967438ad140f65da801a543f43775d Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Wed, 28 Sep 2022 15:02:09 +0000
Subject: [PATCH] feat: add premium podcasts to manage subscriptions for
 premium episodes

closes #193
---
 app/Config/Autoload.php                       |   1 +
 app/Config/Filters.php                        |   5 +
 app/Controllers/BaseController.php            |   2 +-
 app/Controllers/FeedController.php            |  23 +-
 app/Controllers/PostController.php            |   2 +-
 .../2020-05-30-101500_add_podcasts.php        |   5 +
 .../2020-06-05-170000_add_episodes.php        |   5 +
 app/Database/Seeds/AuthSeeder.php             |   6 +
 app/Entities/Episode.php                      |  36 +-
 app/Entities/Media/BaseMedia.php              |  23 +
 app/Entities/Podcast.php                      |  43 +-
 app/Helpers/media_helper.php                  |   2 +-
 app/Helpers/rss_helper.php                    |  23 +-
 app/Helpers/url_helper.php                    |  16 +
 app/Models/EpisodeModel.php                   |  10 +
 app/Models/PodcastModel.php                   |   1 +
 app/Resources/icons/exchange-dollar.svg       |   6 +
 app/Resources/icons/lock-unlock.svg           |   6 +
 app/Resources/icons/lock.svg                  |   6 +
 app/Resources/js/app.ts                       |   2 +
 .../js/modules/play-episode-button.ts         |   3 +-
 app/Resources/styles/custom.css               |   4 +
 app/Views/Components/Button.php               |   6 +-
 app/Views/Components/DropdownMenu.php         |   2 +-
 app/Views/Components/Icon.php                 |   7 +-
 .../Admin/Controllers/EpisodeController.php   |   2 +
 .../Admin/Controllers/PodcastController.php   |   2 +
 .../Admin/Controllers/SettingsController.php  |   8 +
 modules/Admin/Language/en/Breadcrumb.php      |   1 +
 modules/Admin/Language/en/Episode.php         |   2 +
 modules/Admin/Language/en/Podcast.php         |   3 +
 .../Admin/Language/en/PodcastNavigation.php   |   3 +
 modules/Admin/Language/en/Settings.php        |   2 +
 .../EpisodeAnalyticsController.php            |  47 +-
 ...7-12-01-000000_add_analytics_podcasts.php} |   0
 ...000_add_analytics_podcasts_by_episode.php} |   0
 ...020000_add_analytics_podcasts_by_hour.php} |   0
 ...0000_add_analytics_podcasts_by_player.php} |   0
 ...000_add_analytics_podcasts_by_country.php} |   0
 ...0000_add_analytics_podcasts_by_region.php} |   0
 ...0000_add_analytics_website_by_browser.php} |   0
 ...0000_add_analytics_website_by_referer.php} |   0
 ...0_add_analytics_website_by_entry_page.php} |   0
 ...0000_add_analytics_unknown_useragents.php} |   0
 ...add_analytics_podcasts_by_subscription.php |  55 +++
 ...10000_add_analytics_podcasts_procedure.php |   9 +-
 .../AnalyticsPodcastsBySubscription.php       |  40 ++
 .../Analytics/Helpers/analytics_helper.php    |  12 +-
 .../AnalyticsPodcastBySubscriptionModel.php   |  61 +++
 .../Install/Controllers/InstallController.php |   2 +
 modules/PremiumPodcasts/Config/Routes.php     | 139 ++++++
 modules/PremiumPodcasts/Config/Services.php   |  26 +
 .../Controllers/LockController.php            | 118 +++++
 .../Controllers/SubscriptionController.php    | 447 ++++++++++++++++++
 .../2022-07-07-120000_add_subscriptions.php   |  80 ++++
 .../PremiumPodcasts/Entities/Subscription.php | 123 +++++
 .../Filters/PodcastUnlockFilter.php           |  86 ++++
 .../Helpers/premium_podcasts_helper.php       |  35 ++
 .../Language/en/PremiumPodcasts.php           |  34 ++
 .../Language/en/Subscription.php              | 100 ++++
 .../Models/SubscriptionModel.php              | 141 ++++++
 modules/PremiumPodcasts/PremiumPodcasts.php   | 114 +++++
 phpstan.neon                                  |   1 +
 public/media/podcasts/index.html              |   9 -
 tailwind.config.js                            |   3 +
 themes/cp_admin/_layout.php                   |  11 +-
 themes/cp_admin/_partials/_nav_aside.php      |   2 +-
 themes/cp_admin/episode/_card.php             |  11 +-
 themes/cp_admin/episode/_sidebar.php          |   5 +-
 themes/cp_admin/episode/create.php            |   7 +
 themes/cp_admin/episode/edit.php              |   6 +-
 themes/cp_admin/episode/list.php              |   6 +
 themes/cp_admin/podcast/_card.php             |  28 +-
 themes/cp_admin/podcast/_sidebar.php          |   9 +-
 themes/cp_admin/podcast/create.php            |   5 +
 themes/cp_admin/podcast/edit.php              |   5 +
 themes/cp_admin/settings/general.php          |   7 +-
 themes/cp_admin/subscription/add.php          |  35 ++
 themes/cp_admin/subscription/delete.php       |  29 ++
 themes/cp_admin/subscription/edit.php         |  39 ++
 .../subscription/email/_credentials_list.php  |   4 +
 .../cp_admin/subscription/email/_footer.php   |   6 +
 .../subscription/email/_how_to_use.php        |   8 +
 themes/cp_admin/subscription/email/edited.php |  18 +
 .../cp_admin/subscription/email/removed.php   |   7 +
 themes/cp_admin/subscription/email/reset.php  |  13 +
 .../cp_admin/subscription/email/resumed.php   |   7 +
 .../cp_admin/subscription/email/suspended.php |  11 +
 .../cp_admin/subscription/email/welcome.php   |  23 +
 themes/cp_admin/subscription/list.php         | 130 +++++
 themes/cp_admin/subscription/suspend.php      |  36 ++
 themes/cp_admin/subscription/view.php         |  19 +
 themes/cp_app/episode/_layout.php             |   6 +-
 themes/cp_app/episode/_partials/card.php      |  35 +-
 .../cp_app/episode/_partials/preview_card.php |  33 +-
 themes/cp_app/home.php                        |  11 +-
 themes/cp_app/podcast/_layout.php             |   3 +-
 .../podcast/_partials/premium_banner.php      |  46 ++
 themes/cp_app/podcast/_partials/sidebar.php   |   2 +-
 themes/cp_app/podcast/activity.php            |   4 +-
 themes/cp_app/podcast/unlock.php              | 105 ++++
 101 files changed, 2572 insertions(+), 110 deletions(-)
 create mode 100644 app/Resources/icons/exchange-dollar.svg
 create mode 100644 app/Resources/icons/lock-unlock.svg
 create mode 100644 app/Resources/icons/lock.svg
 rename modules/Analytics/Database/Migrations/{2017-12-01-120000_add_analytics_podcasts.php => 2017-12-01-000000_add_analytics_podcasts.php} (100%)
 rename modules/Analytics/Database/Migrations/{2017-12-01-130000_add_analytics_podcasts_by_episode.php => 2017-12-01-010000_add_analytics_podcasts_by_episode.php} (100%)
 rename modules/Analytics/Database/Migrations/{2017-12-01-130000_add_analytics_podcasts_by_hour.php => 2017-12-01-020000_add_analytics_podcasts_by_hour.php} (100%)
 rename modules/Analytics/Database/Migrations/{2017-12-01-140000_add_analytics_podcasts_by_player.php => 2017-12-01-030000_add_analytics_podcasts_by_player.php} (100%)
 rename modules/Analytics/Database/Migrations/{2017-12-01-150000_add_analytics_podcasts_by_country.php => 2017-12-01-040000_add_analytics_podcasts_by_country.php} (100%)
 rename modules/Analytics/Database/Migrations/{2017-12-01-160000_add_analytics_podcasts_by_region.php => 2017-12-01-050000_add_analytics_podcasts_by_region.php} (100%)
 rename modules/Analytics/Database/Migrations/{2017-12-01-170000_add_analytics_website_by_browser.php => 2017-12-01-060000_add_analytics_website_by_browser.php} (100%)
 rename modules/Analytics/Database/Migrations/{2017-12-01-180000_add_analytics_website_by_referer.php => 2017-12-01-070000_add_analytics_website_by_referer.php} (100%)
 rename modules/Analytics/Database/Migrations/{2017-12-01-190000_add_analytics_website_by_entry_page.php => 2017-12-01-080000_add_analytics_website_by_entry_page.php} (100%)
 rename modules/Analytics/Database/Migrations/{2017-12-01-200000_add_analytics_unknown_useragents.php => 2017-12-01-090000_add_analytics_unknown_useragents.php} (100%)
 create mode 100644 modules/Analytics/Database/Migrations/2017-12-01-100000_add_analytics_podcasts_by_subscription.php
 create mode 100644 modules/Analytics/Entities/AnalyticsPodcastsBySubscription.php
 create mode 100644 modules/Analytics/Models/AnalyticsPodcastBySubscriptionModel.php
 create mode 100644 modules/PremiumPodcasts/Config/Routes.php
 create mode 100644 modules/PremiumPodcasts/Config/Services.php
 create mode 100644 modules/PremiumPodcasts/Controllers/LockController.php
 create mode 100644 modules/PremiumPodcasts/Controllers/SubscriptionController.php
 create mode 100644 modules/PremiumPodcasts/Database/Migrations/2022-07-07-120000_add_subscriptions.php
 create mode 100644 modules/PremiumPodcasts/Entities/Subscription.php
 create mode 100644 modules/PremiumPodcasts/Filters/PodcastUnlockFilter.php
 create mode 100644 modules/PremiumPodcasts/Helpers/premium_podcasts_helper.php
 create mode 100644 modules/PremiumPodcasts/Language/en/PremiumPodcasts.php
 create mode 100644 modules/PremiumPodcasts/Language/en/Subscription.php
 create mode 100644 modules/PremiumPodcasts/Models/SubscriptionModel.php
 create mode 100644 modules/PremiumPodcasts/PremiumPodcasts.php
 delete mode 100644 public/media/podcasts/index.html
 create mode 100644 themes/cp_admin/subscription/add.php
 create mode 100644 themes/cp_admin/subscription/delete.php
 create mode 100644 themes/cp_admin/subscription/edit.php
 create mode 100644 themes/cp_admin/subscription/email/_credentials_list.php
 create mode 100644 themes/cp_admin/subscription/email/_footer.php
 create mode 100644 themes/cp_admin/subscription/email/_how_to_use.php
 create mode 100644 themes/cp_admin/subscription/email/edited.php
 create mode 100644 themes/cp_admin/subscription/email/removed.php
 create mode 100644 themes/cp_admin/subscription/email/reset.php
 create mode 100644 themes/cp_admin/subscription/email/resumed.php
 create mode 100644 themes/cp_admin/subscription/email/suspended.php
 create mode 100644 themes/cp_admin/subscription/email/welcome.php
 create mode 100644 themes/cp_admin/subscription/list.php
 create mode 100644 themes/cp_admin/subscription/suspend.php
 create mode 100644 themes/cp_admin/subscription/view.php
 create mode 100644 themes/cp_app/podcast/_partials/premium_banner.php
 create mode 100644 themes/cp_app/podcast/unlock.php

diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php
index 8c5f6f0138..f314d3d20c 100644
--- a/app/Config/Autoload.php
+++ b/app/Config/Autoload.php
@@ -51,6 +51,7 @@ class Autoload extends AutoloadConfig
         'Modules\Fediverse' => ROOTPATH . 'modules/Fediverse/',
         'Modules\WebSub' => ROOTPATH . 'modules/WebSub/',
         'Modules\Api\Rest\V1' => ROOTPATH . 'modules/Api/Rest/V1',
+        'Modules\PremiumPodcasts' => ROOTPATH . 'modules/PremiumPodcasts/',
         'Config' => APPPATH . 'Config/',
         'ViewComponents' => APPPATH . 'Libraries/ViewComponents/',
         'ViewThemes' => APPPATH . 'Libraries/ViewThemes/',
diff --git a/app/Config/Filters.php b/app/Config/Filters.php
index 8d893b746d..54fa52b3b4 100644
--- a/app/Config/Filters.php
+++ b/app/Config/Filters.php
@@ -14,6 +14,7 @@ use Modules\Api\Rest\V1\Filters\ApiFilter;
 use Modules\Auth\Filters\PermissionFilter;
 use Modules\Fediverse\Filters\AllowCorsFilter;
 use Modules\Fediverse\Filters\FediverseFilter;
+use Modules\PremiumPodcasts\Filters\PodcastUnlockFilter;
 use Myth\Auth\Filters\LoginFilter;
 use Myth\Auth\Filters\RoleFilter;
 
@@ -36,6 +37,7 @@ class Filters extends BaseConfig
         'fediverse' => FediverseFilter::class,
         'allow-cors' => AllowCorsFilter::class,
         'rest-api' => ApiFilter::class,
+        'podcast-unlock' => PodcastUnlockFilter::class,
     ];
 
     /**
@@ -87,6 +89,9 @@ class Filters extends BaseConfig
             'login' => [
                 'before' => [config('Admin')->gateway . '*', config('Analytics')->gateway . '*'],
             ],
+            'podcast-unlock' => [
+                'before' => ['*@*/episodes/*'],
+            ],
         ];
     }
 }
diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php
index fb3d273cfd..fd82bc085b 100644
--- a/app/Controllers/BaseController.php
+++ b/app/Controllers/BaseController.php
@@ -28,7 +28,7 @@ abstract class BaseController extends Controller
         ResponseInterface $response,
         LoggerInterface $logger
     ): void {
-        $this->helpers = array_merge($this->helpers, ['auth', 'svg', 'components', 'misc', 'seo']);
+        $this->helpers = array_merge($this->helpers, ['auth', 'svg', 'components', 'misc', 'seo', 'premium_podcasts']);
 
         // Do Not Edit This Line
         parent::initController($request, $response, $logger);
diff --git a/app/Controllers/FeedController.php b/app/Controllers/FeedController.php
index 6f970ee278..e323ede85d 100644
--- a/app/Controllers/FeedController.php
+++ b/app/Controllers/FeedController.php
@@ -10,19 +10,21 @@ declare(strict_types=1);
 
 namespace App\Controllers;
 
+use App\Entities\Podcast;
 use App\Models\EpisodeModel;
 use App\Models\PodcastModel;
 use CodeIgniter\Controller;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\ResponseInterface;
 use Exception;
+use Modules\PremiumPodcasts\Models\SubscriptionModel;
 use Opawg\UserAgentsPhp\UserAgentsRSS;
 
 class FeedController extends Controller
 {
     public function index(string $podcastHandle): ResponseInterface
     {
-        helper('rss');
+        helper(['rss', 'premium_podcasts']);
 
         $podcast = (new PodcastModel())->where('handle', $podcastHandle)
             ->first();
@@ -43,11 +45,24 @@ class FeedController extends Controller
             $serviceSlug = $service['slug'];
         }
 
-        $cacheName =
-            "podcast#{$podcast->id}_feed" . ($service ? "_{$serviceSlug}" : '');
+        $subscription = null;
+        $token = $this->request->getGet('token');
+        if ($token) {
+            $subscription = (new SubscriptionModel())->validateSubscription($podcastHandle, $token);
+        }
+
+        $cacheName = implode(
+            '_',
+            array_filter([
+                "podcast#{$podcast->id}",
+                'feed',
+                $service ? $serviceSlug : null,
+                $subscription !== null ? 'unlocked' : null,
+            ]),
+        );
 
         if (! ($found = cache($cacheName))) {
-            $found = get_rss_feed($podcast, $serviceSlug);
+            $found = get_rss_feed($podcast, $serviceSlug, $subscription, $token);
 
             // The page cache is set to expire after next episode publication or a decade by default so it is deleted manually upon podcast update
             $secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
diff --git a/app/Controllers/PostController.php b/app/Controllers/PostController.php
index 6e6f033e69..3db4ab275c 100644
--- a/app/Controllers/PostController.php
+++ b/app/Controllers/PostController.php
@@ -40,7 +40,7 @@ class PostController extends FediversePostController
     /**
      * @var string[]
      */
-    protected $helpers = ['auth', 'fediverse', 'svg', 'components', 'misc', 'seo'];
+    protected $helpers = ['auth', 'fediverse', 'svg', 'components', 'misc', 'seo', 'premium_podcasts'];
 
     public function _remap(string $method, string ...$params): mixed
     {
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 61f3b86a70..c5093868cd 100644
--- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
+++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
@@ -169,6 +169,11 @@ class AddPodcasts extends Migration
                 'constraint' => 512,
                 'null' => true,
             ],
+            'is_premium_by_default' => [
+                'type' => 'TINYINT',
+                'constraint' => 1,
+                'default' => 0,
+            ],
             'created_by' => [
                 'type' => 'INT',
                 'unsigned' => true,
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 0866930190..8228c9a51c 100644
--- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php
+++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
@@ -129,6 +129,11 @@ class AddEpisodes extends Migration
                 'unsigned' => true,
                 'default' => 0,
             ],
+            'is_premium' => [
+                'type' => 'TINYINT',
+                'constraint' => 1,
+                'default' => 0,
+            ],
             'created_by' => [
                 'type' => 'INT',
                 'unsigned' => true,
diff --git a/app/Database/Seeds/AuthSeeder.php b/app/Database/Seeds/AuthSeeder.php
index 365726e6f7..32181d1879 100644
--- a/app/Database/Seeds/AuthSeeder.php
+++ b/app/Database/Seeds/AuthSeeder.php
@@ -154,6 +154,12 @@ class AuthSeeder extends Seeder
                 'description' => 'Edit a podcast',
                 'has_permission' => ['podcast_admin'],
             ],
+            [
+                'name' => 'manage_subscriptions',
+                'description' =>
+                    'Add / edit / remove podcast subscriptions',
+                'has_permission' => ['podcast_admin'],
+            ],
             [
                 'name' => 'manage_contributors',
                 'description' =>
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index 62dedeafb2..d58059c235 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -73,16 +73,17 @@ use RuntimeException;
  * @property int $posts_count
  * @property int $comments_count
  * @property EpisodeComment[]|null $comments
+ * @property bool $is_premium
  * @property int $created_by
  * @property int $updated_by
- * @property string $publication_status;
- * @property Time|null $published_at;
- * @property Time $created_at;
- * @property Time $updated_at;
+ * @property string $publication_status
+ * @property Time|null $published_at
+ * @property Time $created_at
+ * @property Time $updated_at
  *
- * @property Person[] $persons;
- * @property Soundbite[] $soundbites;
- * @property string $embed_url;
+ * @property Person[] $persons
+ * @property Soundbite[] $soundbites
+ * @property string $embed_url
  */
 class Episode extends Entity
 {
@@ -168,6 +169,7 @@ class Episode extends Entity
         'is_published_on_hubs' => 'boolean',
         'posts_count' => 'integer',
         'comments_count' => 'integer',
+        'is_premium' => 'boolean',
         'created_by' => 'integer',
         'updated_by' => 'integer',
     ];
@@ -233,7 +235,7 @@ class Episode extends Entity
             (new MediaModel('audio'))->updateMedia($this->getAudio());
         } else {
             $audio = new Audio([
-                'file_name' => $this->attributes['slug'],
+                'file_name' => pathinfo($file->getRandomName(), PATHINFO_FILENAME),
                 'file_directory' => 'podcasts/' . $this->getPodcast()->handle,
                 'language_code' => $this->getPodcast()
                     ->language_code,
@@ -337,16 +339,20 @@ class Episode extends Entity
     {
         helper('analytics');
 
-        // remove 'podcasts/' from audio file path
-        $strippedAudioPath = substr($this->getAudio()->file_path, 9);
-
         return generate_episode_analytics_url(
             $this->podcast_id,
             $this->id,
-            $strippedAudioPath,
-            $this->audio->duration,
-            $this->audio->file_size,
-            $this->audio->header_size,
+            $this->getPodcast()
+                ->handle,
+            $this->attributes['slug'],
+            $this->getAudio()
+                ->file_extension,
+            $this->getAudio()
+                ->duration,
+            $this->getAudio()
+                ->file_size,
+            $this->getAudio()
+                ->header_size,
             $this->published_at,
         );
     }
diff --git a/app/Entities/Media/BaseMedia.php b/app/Entities/Media/BaseMedia.php
index 3c0c88ee4f..4bb9b0a19b 100644
--- a/app/Entities/Media/BaseMedia.php
+++ b/app/Entities/Media/BaseMedia.php
@@ -114,4 +114,27 @@ class BaseMedia extends Entity
         $mediaModel = new MediaModel();
         return $mediaModel->delete($this->id);
     }
+
+    public function rename(): bool
+    {
+        $newFilePath = $this->file_directory . '/' . (new File(''))->getRandomName() . '.' . $this->file_extension;
+
+        $db = db_connect();
+        $db->transStart();
+
+        if (! (new MediaModel())->update($this->id, [
+            'file_path' => $newFilePath,
+        ])) {
+            return false;
+        }
+
+        if (! rename(media_path($this->file_path), media_path($newFilePath))) {
+            $db->transRollback();
+            return false;
+        }
+
+        $db->transComplete();
+
+        return true;
+    }
 }
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 7e269bcd71..9472a5c707 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -30,6 +30,8 @@ use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
 use League\CommonMark\Extension\SmartPunct\SmartPunctExtension;
 use League\CommonMark\MarkdownConverter;
 use Modules\Auth\Entities\User;
+use Modules\PremiumPodcasts\Entities\Subscription;
+use Modules\PremiumPodcasts\Models\SubscriptionModel;
 use RuntimeException;
 
 /**
@@ -79,14 +81,17 @@ use RuntimeException;
  * @property string|null $partner_image_url
  * @property int $created_by
  * @property int $updated_by
- * @property string $publication_status;
- * @property Time|null $published_at;
- * @property Time $created_at;
- * @property Time $updated_at;
+ * @property string $publication_status
+ * @property bool $is_premium_by_default
+ * @property bool $is_premium
+ * @property Time|null $published_at
+ * @property Time $created_at
+ * @property Time $updated_at
  *
  * @property Episode[] $episodes
  * @property Person[] $persons
  * @property User[] $contributors
+ * @property Subscription[] $subscriptions
  * @property Platform[] $podcasting_platforms
  * @property Platform[] $social_platforms
  * @property Platform[] $funding_platforms
@@ -130,6 +135,11 @@ class Podcast extends Entity
      */
     protected ?array $contributors = null;
 
+    /**
+     * @var Subscription[]|null
+     */
+    protected ?array $subscriptions = null;
+
     /**
      * @var Platform[]|null
      */
@@ -182,6 +192,7 @@ class Podcast extends Entity
         'is_blocked' => 'boolean',
         'is_completed' => 'boolean',
         'is_locked' => 'boolean',
+        'is_premium_by_default' => 'boolean',
         'imported_feed_url' => '?string',
         'new_feed_url' => '?string',
         'location_name' => '?string',
@@ -380,6 +391,24 @@ class Podcast extends Entity
         return $this->category;
     }
 
+    /**
+     * Returns all podcast subscriptions
+     *
+     * @return Subscription[]
+     */
+    public function getSubscriptions(): array
+    {
+        if ($this->id === null) {
+            throw new RuntimeException('Podcasts must be created before getting subscriptions.');
+        }
+
+        if ($this->subscriptions === null) {
+            $this->subscriptions = (new SubscriptionModel())->getPodcastSubscriptions($this->id);
+        }
+
+        return $this->subscriptions;
+    }
+
     /**
      * Returns all podcast contributors
      *
@@ -652,4 +681,10 @@ class Podcast extends Entity
 
         return $this;
     }
+
+    public function getIsPremium(): bool
+    {
+        // podcast is premium if at least one of its episodes is set as premium
+        return (new EpisodeModel())->doesPodcastHavePremiumEpisodes($this->id);
+    }
 }
diff --git a/app/Helpers/media_helper.php b/app/Helpers/media_helper.php
index 088096b414..f77d8ef917 100644
--- a/app/Helpers/media_helper.php
+++ b/app/Helpers/media_helper.php
@@ -18,7 +18,7 @@ if (! function_exists('save_media')) {
     /**
      * Saves a file to the corresponding podcast folder in `public/media`
      */
-    function save_media(File | UploadedFile $file, string $folder = '', string $filename = ''): string
+    function save_media(File | UploadedFile $file, string $folder = '', string $filename = null): string
     {
         if (($extension = $file->getExtension()) !== '') {
             $filename = $filename . '.' . $extension;
diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php
index c1b123c1b9..16845ae916 100644
--- a/app/Helpers/rss_helper.php
+++ b/app/Helpers/rss_helper.php
@@ -13,6 +13,7 @@ use App\Entities\Podcast;
 use App\Libraries\SimpleRSSElement;
 use CodeIgniter\I18n\Time;
 use Config\Mimes;
+use Modules\PremiumPodcasts\Entities\Subscription;
 
 if (! function_exists('get_rss_feed')) {
     /**
@@ -21,8 +22,12 @@ if (! function_exists('get_rss_feed')) {
      * @param string $serviceSlug The name of the service that fetches the RSS feed for future reference when the audio file is eventually downloaded
      * @return string rss feed as xml
      */
-    function get_rss_feed(Podcast $podcast, string $serviceSlug = ''): string
-    {
+    function get_rss_feed(
+        Podcast $podcast,
+        string $serviceSlug = '',
+        Subscription $subscription = null,
+        string $token = null
+    ): string {
         $episodes = $podcast->episodes;
 
         $itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
@@ -267,16 +272,22 @@ if (! function_exists('get_rss_feed')) {
         }
 
         foreach ($episodes as $episode) {
+            if ($episode->is_premium && $subscription === null) {
+                continue;
+            }
+
             $item = $channel->addChild('item');
             $item->addChild('title', $episode->title, null, false);
             $enclosure = $item->addChild('enclosure');
 
+            $enclosureParams = implode('&', array_filter([
+                $episode->is_premium ? 'token=' . $token : null,
+                $serviceSlug !== '' ? '_from=' . urlencode($serviceSlug) : null,
+            ]));
+
             $enclosure->addAttribute(
                 'url',
-                $episode->audio_analytics_url .
-                    ($serviceSlug === ''
-                        ? ''
-                        : '?_from=' . urlencode($serviceSlug)),
+                $episode->audio_analytics_url . ($enclosureParams === '' ? '' : '?' . $enclosureParams),
             );
             $enclosure->addAttribute('length', (string) $episode->audio->file_size);
             $enclosure->addAttribute('type', $episode->audio->file_mimetype);
diff --git a/app/Helpers/url_helper.php b/app/Helpers/url_helper.php
index 4e1b41a3aa..abddd00dca 100644
--- a/app/Helpers/url_helper.php
+++ b/app/Helpers/url_helper.php
@@ -31,6 +31,22 @@ if (! function_exists('host_url')) {
 
 //--------------------------------------------------------------------
 
+/**
+ * Return the host URL to use in views
+ */
+if (! function_exists('current_domain')) {
+    /**
+     * Returns instance's domain name
+     */
+    function current_domain(): string
+    {
+        $uri = current_url(true);
+        return $uri->getHost() . ($uri->getPort() ? ':' . $uri->getPort() : '');
+    }
+}
+
+//--------------------------------------------------------------------
+
 if (! function_exists('extract_params_from_episode_uri')) {
     /**
      * Returns podcast name and episode slug from episode string
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index 6ceb231b75..dcf40ba5b3 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -85,6 +85,7 @@ class EpisodeModel extends Model
         'is_published_on_hubs',
         'posts_count',
         'comments_count',
+        'is_premium',
         'published_at',
         'created_by',
         'updated_by',
@@ -413,6 +414,15 @@ class EpisodeModel extends Model
         return $data;
     }
 
+    public function doesPodcastHavePremiumEpisodes(int $podcastId): bool
+    {
+        return $this->builder()
+            ->where([
+                'podcast_id' => $podcastId,
+                'is_premium' => true,
+            ])->countAllResults() > 0;
+    }
+
     /**
      * @param mixed[] $data
      *
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index 8a5fd8c647..af3098d220 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -64,6 +64,7 @@ class PodcastModel extends Model
         'partner_id',
         'partner_link_url',
         'partner_image_url',
+        'is_premium_by_default',
         'published_at',
         'created_by',
         'updated_by',
diff --git a/app/Resources/icons/exchange-dollar.svg b/app/Resources/icons/exchange-dollar.svg
new file mode 100644
index 0000000000..85cc6af064
--- /dev/null
+++ b/app/Resources/icons/exchange-dollar.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+    <g>
+        <path fill="none" d="M0 0h24v24H0z"/>
+        <path d="M5.373 4.51A9.962 9.962 0 0 1 12 2c5.523 0 10 4.477 10 10a9.954 9.954 0 0 1-1.793 5.715L17.5 12H20A8 8 0 0 0 6.274 6.413l-.9-1.902zm13.254 14.98A9.962 9.962 0 0 1 12 22C6.477 22 2 17.523 2 12c0-2.125.663-4.095 1.793-5.715L6.5 12H4a8 8 0 0 0 13.726 5.587l.9 1.902zM8.5 14H14a.5.5 0 1 0 0-1h-4a2.5 2.5 0 1 1 0-5h1V7h2v1h2.5v2H10a.5.5 0 1 0 0 1h4a2.5 2.5 0 1 1 0 5h-1v1h-2v-1H8.5v-2z"/>
+    </g>
+</svg>
\ No newline at end of file
diff --git a/app/Resources/icons/lock-unlock.svg b/app/Resources/icons/lock-unlock.svg
new file mode 100644
index 0000000000..0ea4517c22
--- /dev/null
+++ b/app/Resources/icons/lock-unlock.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+    <g>
+        <path fill="none" d="M0 0h24v24H0z"/>
+        <path d="M7 10h13a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V11a1 1 0 0 1 1-1h1V9a7 7 0 0 1 13.262-3.131l-1.789.894A5 5 0 0 0 7 9v1zm3 5v2h4v-2h-4z"/>
+    </g>
+</svg>
diff --git a/app/Resources/icons/lock.svg b/app/Resources/icons/lock.svg
new file mode 100644
index 0000000000..a54f3e4239
--- /dev/null
+++ b/app/Resources/icons/lock.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+    <g>
+        <path fill="none" d="M0 0h24v24H0z"/>
+        <path d="M18 8h2a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h2V7a6 6 0 1 1 12 0v1zm-7 7.732V18h2v-2.268a2 2 0 1 0-2 0zM16 8V7a4 4 0 1 0-8 0v1h8z"/>
+    </g>
+</svg>
diff --git a/app/Resources/js/app.ts b/app/Resources/js/app.ts
index f1093cda32..7b944f47df 100644
--- a/app/Resources/js/app.ts
+++ b/app/Resources/js/app.ts
@@ -1,3 +1,5 @@
 import Dropdown from "./modules/Dropdown";
+import Tooltip from "./modules/Tooltip";
 
 Dropdown();
+Tooltip();
diff --git a/app/Resources/js/modules/play-episode-button.ts b/app/Resources/js/modules/play-episode-button.ts
index e9e7e1c9aa..3eee7af509 100644
--- a/app/Resources/js/modules/play-episode-button.ts
+++ b/app/Resources/js/modules/play-episode-button.ts
@@ -131,8 +131,7 @@ export class PlayEpisodeButton extends LitElement {
 
   private _showPlayer(): void {
     this._castopodAudioPlayer.style.display = "";
-    document.body.classList.add("pb-[105px]");
-    document.body.classList.add("sm:pb-[52px]");
+    document.body.classList.add("pb-[105px]", "sm:pb-[52px]");
   }
 
   private _flushLastPlayButton(playingEpisodeButton: PlayEpisodeButton): void {
diff --git a/app/Resources/styles/custom.css b/app/Resources/styles/custom.css
index fc539d2781..8cfb46bccb 100644
--- a/app/Resources/styles/custom.css
+++ b/app/Resources/styles/custom.css
@@ -28,6 +28,10 @@
     border-radius: max(0px, min(1rem, calc((100vw - 1rem - 100%) * 9999)));
   }
 
+  .rounded-conditional-full {
+    border-radius: max(0px, min(9999px, calc((100vw - 1rem - 100%) * 9999)));
+  }
+
   .backdrop-gradient {
     background-image: linear-gradient(
       180deg,
diff --git a/app/Views/Components/Button.php b/app/Views/Components/Button.php
index a79f7848db..292adf4817 100644
--- a/app/Views/Components/Button.php
+++ b/app/Views/Components/Button.php
@@ -28,7 +28,7 @@ class Button extends Component
     public function render(): string
     {
         $baseClass =
-            'flex-shrink-0 inline-flex items-center justify-center font-semibold shadow-xs rounded-full focus:ring-accent';
+            'gap-x-2 flex-shrink-0 inline-flex items-center justify-center font-semibold shadow-xs rounded-full focus:ring-accent';
 
         $variantClass = [
             'default' => 'text-black bg-gray-300 hover:bg-gray-400',
@@ -84,14 +84,14 @@ class Button extends Component
         if ($this->iconLeft !== '') {
             $this->slot = (new Icon([
                 'glyph' => $this->iconLeft,
-                'class' => 'mr-2 opacity-75' . ' ' . $iconSize[$this->size],
+                'class' => 'opacity-75' . ' ' . $iconSize[$this->size],
             ]))->render() . $this->slot;
         }
 
         if ($this->iconRight !== '') {
             $this->slot .= (new Icon([
                 'glyph' => $this->iconRight,
-                'class' => 'ml-2 opacity-75' . ' ' . $iconSize[$this->size],
+                'class' => 'opacity-75' . ' ' . $iconSize[$this->size],
             ]))->render();
         }
 
diff --git a/app/Views/Components/DropdownMenu.php b/app/Views/Components/DropdownMenu.php
index 4b097134ff..bed2220f91 100644
--- a/app/Views/Components/DropdownMenu.php
+++ b/app/Views/Components/DropdownMenu.php
@@ -53,7 +53,7 @@ class DropdownMenu extends Component
 
         return <<<HTML
             <nav id="{$this->id}"
-                class="absolute z-50 flex flex-col py-2 rounded-lg whitespace-nowrap text-skin-base border-contrast bg-elevated border-3"
+                class="absolute flex flex-col py-2 rounded-lg z-60 whitespace-nowrap text-skin-base border-contrast bg-elevated border-3"
                 aria-labelledby="{$this->labelledby}"
                 data-dropdown="menu"
                 data-dropdown-placement="{$this->placement}"
diff --git a/app/Views/Components/Icon.php b/app/Views/Components/Icon.php
index 257ebb7b71..f926eb45e7 100644
--- a/app/Views/Components/Icon.php
+++ b/app/Views/Components/Icon.php
@@ -19,10 +19,9 @@ class Icon extends Component
             return 'â–¡';
         }
 
-        if ($this->attributes['class'] !== '') {
-            return str_replace('<svg', '<svg class="' . $this->attributes['class'] . '"', $svgContents);
-        }
+        unset($this->attributes['glyph']);
+        $attributes = stringify_attributes($this->attributes);
 
-        return $svgContents;
+        return str_replace('<svg', '<svg ' . $attributes, $svgContents);
     }
 }
diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php
index d58be7ba42..9e5e906f70 100644
--- a/modules/Admin/Controllers/EpisodeController.php
+++ b/modules/Admin/Controllers/EpisodeController.php
@@ -193,6 +193,7 @@ class EpisodeController extends BaseController
             'type' => $this->request->getPost('type'),
             'is_blocked' => $this->request->getPost('block') === 'yes',
             'custom_rss_string' => $this->request->getPost('custom_rss'),
+            'is_premium' => $this->request->getPost('premium') === 'yes',
             'created_by' => user_id(),
             'updated_by' => user_id(),
             'published_at' => null,
@@ -308,6 +309,7 @@ class EpisodeController extends BaseController
         $this->episode->type = $this->request->getPost('type');
         $this->episode->is_blocked = $this->request->getPost('block') === 'yes';
         $this->episode->custom_rss_string = $this->request->getPost('custom_rss');
+        $this->episode->is_premium = $this->request->getPost('premium') === 'yes';
 
         $this->episode->updated_by = (int) user_id();
         $this->episode->setAudio($this->request->getFile('audio_file'));
diff --git a/modules/Admin/Controllers/PodcastController.php b/modules/Admin/Controllers/PodcastController.php
index e08138773e..7e9726affa 100644
--- a/modules/Admin/Controllers/PodcastController.php
+++ b/modules/Admin/Controllers/PodcastController.php
@@ -238,6 +238,7 @@ class PodcastController extends BaseController
             'is_blocked' => $this->request->getPost('block') === 'yes',
             'is_completed' => $this->request->getPost('complete') === 'yes',
             'is_locked' => $this->request->getPost('lock') === 'yes',
+            'is_premium_by_default' => $this->request->getPost('premium_by_default') === 'yes',
             'created_by' => user_id(),
             'updated_by' => user_id(),
             'published_at' => null,
@@ -351,6 +352,7 @@ class PodcastController extends BaseController
         $this->podcast->is_completed =
             $this->request->getPost('complete') === 'yes';
         $this->podcast->is_locked = $this->request->getPost('lock') === 'yes';
+        $this->podcast->is_premium_by_default = $this->request->getPost('premium_by_default') === 'yes';
         $this->podcast->updated_by = (int) user_id();
 
         // republish on websub hubs upon edit
diff --git a/modules/Admin/Controllers/SettingsController.php b/modules/Admin/Controllers/SettingsController.php
index 7562546038..8fd936f4b9 100644
--- a/modules/Admin/Controllers/SettingsController.php
+++ b/modules/Admin/Controllers/SettingsController.php
@@ -288,6 +288,14 @@ class SettingsController extends BaseController
             cache()->clean();
         }
 
+        if ($this->request->getPost('rename_episodes_files') === 'yes') {
+            $allAudio = (new MediaModel('audio'))->getAllOfType();
+
+            foreach ($allAudio as $audio) {
+                $audio->rename();
+            }
+        }
+
         return redirect('settings-general')->with('message', lang('Settings.housekeeping.runSuccess'));
     }
 
diff --git a/modules/Admin/Language/en/Breadcrumb.php b/modules/Admin/Language/en/Breadcrumb.php
index 24bece0140..4b4cd3a0bf 100644
--- a/modules/Admin/Language/en/Breadcrumb.php
+++ b/modules/Admin/Language/en/Breadcrumb.php
@@ -14,6 +14,7 @@ return [
         ->gateway => 'Home',
     'podcasts' => 'podcasts',
     'episodes' => 'episodes',
+    'subscriptions' => 'subscriptions',
     'contributors' => 'contributors',
     'pages' => 'pages',
     'settings' => 'settings',
diff --git a/modules/Admin/Language/en/Episode.php b/modules/Admin/Language/en/Episode.php
index ba0922f519..f800ee95eb 100644
--- a/modules/Admin/Language/en/Episode.php
+++ b/modules/Admin/Language/en/Episode.php
@@ -109,6 +109,8 @@ return [
             'bonus' => 'Bonus',
             'bonus_hint' => 'Extra content for the show (for example, behind the scenes info or interviews with the cast) or cross-promotional content for another show',
         ],
+        'premium_title' => 'Premium',
+        'premium' => 'Episode must only be accessible to premium subscribers',
         'parental_advisory' => [
             'label' => 'Parental advisory',
             'hint' => 'Does the episode contain explicit content?',
diff --git a/modules/Admin/Language/en/Podcast.php b/modules/Admin/Language/en/Podcast.php
index 19a022b563..426b763b8b 100644
--- a/modules/Admin/Language/en/Podcast.php
+++ b/modules/Admin/Language/en/Podcast.php
@@ -107,6 +107,9 @@ return [
         'monetization_section_title' => 'Monetization',
         'monetization_section_subtitle' =>
             'Earn money thanks to your audience.',
+        'premium' => 'Premium',
+        'premium_by_default' => 'Episodes must be set as premium by default',
+        'premium_by_default_hint' => 'Podcast episodes will be marked as premium by default. You can still choose to set some episodes, trailers or bonuses as public.',
         'payment_pointer' => 'Payment Pointer for Web Monetization',
         'payment_pointer_hint' =>
             'This is your where you will receive money thanks to Web Monetization',
diff --git a/modules/Admin/Language/en/PodcastNavigation.php b/modules/Admin/Language/en/PodcastNavigation.php
index b619573154..b4d7ddc089 100644
--- a/modules/Admin/Language/en/PodcastNavigation.php
+++ b/modules/Admin/Language/en/PodcastNavigation.php
@@ -25,6 +25,9 @@ return [
     'podcast-analytics-players' => 'Players',
     'podcast-analytics-listening-time' => 'Listening time',
     'podcast-analytics-time-periods' => 'Time periods',
+    'premium' => 'Premium',
+    'subscription-list' => 'All subscriptions',
+    'subscription-add' => 'Add subscription',
     'contributors' => 'Contributors',
     'contributor-list' => 'All contributors',
     'contributor-add' => 'Add contributor',
diff --git a/modules/Admin/Language/en/Settings.php b/modules/Admin/Language/en/Settings.php
index 345976be7f..4a70dcbaa0 100644
--- a/modules/Admin/Language/en/Settings.php
+++ b/modules/Admin/Language/en/Settings.php
@@ -35,6 +35,8 @@ return [
         'reset_counts_helper' => 'This option will recalculate and reset all data counts (number of followers, posts, comments, …).',
         'rewrite_media' => 'Rewrite media metadata',
         'rewrite_media_helper' => 'This option will delete all superfluous media files and recreate them (images, audio files, transcripts, chapters, …)',
+        'rename_episodes_files' => 'Rename episode audio files',
+        'rename_episodes_files_hint' => 'This option will rename all episodes audio files to a random string of characters. Use this if one of your private episodes link was leaked as this will effectively hide it.',
         'clear_cache' => 'Clear all cache',
         'clear_cache_helper' => 'This option will flush redis cache or writable/cache files.',
         'run' => 'Run housekeeping',
diff --git a/modules/Analytics/Controllers/EpisodeAnalyticsController.php b/modules/Analytics/Controllers/EpisodeAnalyticsController.php
index 5d7ec14024..e1244d5194 100644
--- a/modules/Analytics/Controllers/EpisodeAnalyticsController.php
+++ b/modules/Analytics/Controllers/EpisodeAnalyticsController.php
@@ -10,12 +10,16 @@ declare(strict_types=1);
 
 namespace Modules\Analytics\Controllers;
 
+use App\Entities\Episode;
+use App\Models\EpisodeModel;
 use CodeIgniter\Controller;
+use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
 use CodeIgniter\HTTP\RequestInterface;
 use CodeIgniter\HTTP\ResponseInterface;
 use Config\Services;
 use Modules\Analytics\Config\Analytics;
+use Modules\PremiumPodcasts\Models\SubscriptionModel;
 use Psr\Log\LoggerInterface;
 
 class EpisodeAnalyticsController extends Controller
@@ -48,14 +52,14 @@ class EpisodeAnalyticsController extends Controller
         $this->config = config('Analytics');
     }
 
-    public function hit(string $base64EpisodeData, string ...$audioPath): RedirectResponse
+    public function hit(string $base64EpisodeData, string ...$audioPath): RedirectResponse|ResponseInterface
     {
         $session = Services::session();
         $session->start();
 
         $serviceName = '';
-        if (isset($_GET['_from'])) {
-            $serviceName = $_GET['_from'];
+        if ($this->request->getGet('_from')) {
+            $serviceName = $this->request->getGet('_from');
         } elseif ($session->get('embed_domain') !== null) {
             $serviceName = $session->get('embed_domain');
         } elseif ($session->get('referer') !== null && $session->get('referer') !== '- Direct -') {
@@ -67,6 +71,40 @@ class EpisodeAnalyticsController extends Controller
             base64_url_decode($base64EpisodeData),
         );
 
+        if (! $episodeData) {
+            throw PageNotFoundException::forPageNotFound();
+        }
+
+        // check if episode is premium?
+        $episode = (new EpisodeModel())->getEpisodeById($episodeData['episodeId']);
+
+        if (! $episode instanceof Episode) {
+            return $this->response->setStatusCode(404);
+        }
+
+        $subscription = null;
+
+        // check if podcast is already unlocked before any token validation
+        if ($episode->is_premium && ($subscription = service('premium_podcasts')->subscription(
+            $episode->podcast->handle
+        )) === null) {
+            // look for token as GET parameter
+            if (($token = $this->request->getGet('token')) === null) {
+                return $this->response->setStatusCode(
+                    401,
+                    'Episode is premium, you must provide a token to unlock it.'
+                );
+            }
+
+            // check if there's a valid subscription for the provided token
+            if (($subscription = (new SubscriptionModel())->validateSubscription(
+                $episode->podcast->handle,
+                $token
+            )) === null) {
+                return $this->response->setStatusCode(401, 'Invalid token!');
+            }
+        }
+
         podcast_hit(
             $episodeData['podcastId'],
             $episodeData['episodeId'],
@@ -75,8 +113,9 @@ class EpisodeAnalyticsController extends Controller
             $episodeData['duration'],
             $episodeData['publicationDate'],
             $serviceName,
+            $subscription !== null ? $subscription->id : null
         );
 
-        return redirect()->to($this->config->getAudioUrl(['podcasts', ...$audioPath]));
+        return redirect()->to($this->config->getAudioUrl($episode->audio->file_path));
     }
 }
diff --git a/modules/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php b/modules/Analytics/Database/Migrations/2017-12-01-000000_add_analytics_podcasts.php
similarity index 100%
rename from modules/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php
rename to modules/Analytics/Database/Migrations/2017-12-01-000000_add_analytics_podcasts.php
diff --git a/modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php b/modules/Analytics/Database/Migrations/2017-12-01-010000_add_analytics_podcasts_by_episode.php
similarity index 100%
rename from modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php
rename to modules/Analytics/Database/Migrations/2017-12-01-010000_add_analytics_podcasts_by_episode.php
diff --git a/modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php b/modules/Analytics/Database/Migrations/2017-12-01-020000_add_analytics_podcasts_by_hour.php
similarity index 100%
rename from modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php
rename to modules/Analytics/Database/Migrations/2017-12-01-020000_add_analytics_podcasts_by_hour.php
diff --git a/modules/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php b/modules/Analytics/Database/Migrations/2017-12-01-030000_add_analytics_podcasts_by_player.php
similarity index 100%
rename from modules/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php
rename to modules/Analytics/Database/Migrations/2017-12-01-030000_add_analytics_podcasts_by_player.php
diff --git a/modules/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php b/modules/Analytics/Database/Migrations/2017-12-01-040000_add_analytics_podcasts_by_country.php
similarity index 100%
rename from modules/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php
rename to modules/Analytics/Database/Migrations/2017-12-01-040000_add_analytics_podcasts_by_country.php
diff --git a/modules/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php b/modules/Analytics/Database/Migrations/2017-12-01-050000_add_analytics_podcasts_by_region.php
similarity index 100%
rename from modules/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php
rename to modules/Analytics/Database/Migrations/2017-12-01-050000_add_analytics_podcasts_by_region.php
diff --git a/modules/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php b/modules/Analytics/Database/Migrations/2017-12-01-060000_add_analytics_website_by_browser.php
similarity index 100%
rename from modules/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php
rename to modules/Analytics/Database/Migrations/2017-12-01-060000_add_analytics_website_by_browser.php
diff --git a/modules/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php b/modules/Analytics/Database/Migrations/2017-12-01-070000_add_analytics_website_by_referer.php
similarity index 100%
rename from modules/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php
rename to modules/Analytics/Database/Migrations/2017-12-01-070000_add_analytics_website_by_referer.php
diff --git a/modules/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php b/modules/Analytics/Database/Migrations/2017-12-01-080000_add_analytics_website_by_entry_page.php
similarity index 100%
rename from modules/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php
rename to modules/Analytics/Database/Migrations/2017-12-01-080000_add_analytics_website_by_entry_page.php
diff --git a/modules/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php b/modules/Analytics/Database/Migrations/2017-12-01-090000_add_analytics_unknown_useragents.php
similarity index 100%
rename from modules/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php
rename to modules/Analytics/Database/Migrations/2017-12-01-090000_add_analytics_unknown_useragents.php
diff --git a/modules/Analytics/Database/Migrations/2017-12-01-100000_add_analytics_podcasts_by_subscription.php b/modules/Analytics/Database/Migrations/2017-12-01-100000_add_analytics_podcasts_by_subscription.php
new file mode 100644
index 0000000000..24fd97836a
--- /dev/null
+++ b/modules/Analytics/Database/Migrations/2017-12-01-100000_add_analytics_podcasts_by_subscription.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\Analytics\Database\Migrations;
+
+use CodeIgniter\Database\Migration;
+
+class AddAnalyticsPodcastsBySubscription extends Migration
+{
+    public function up(): void
+    {
+        $this->forge->addField([
+            'podcast_id' => [
+                'type' => 'INT',
+                'unsigned' => true,
+            ],
+            'episode_id' => [
+                'type' => 'INT',
+                'unsigned' => true,
+            ],
+            'subscription_id' => [
+                'type' => 'INT',
+                'unsigned' => true,
+            ],
+            'date' => [
+                'type' => 'DATE',
+            ],
+            'hits' => [
+                'type' => 'INT',
+                'unsigned' => true,
+                'default' => 1,
+            ],
+        ]);
+
+        $this->forge->addPrimaryKey(['podcast_id', 'episode_id', 'subscription_id', 'date']);
+        // `created_at` and `updated_at` are created with SQL because Model class won’t be used for insertion (Procedure will be used instead)
+        $this->forge->addField('`created_at` timestamp NOT NULL DEFAULT current_timestamp()');
+        $this->forge->addField(
+            '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()',
+        );
+        $this->forge->createTable('analytics_podcasts_by_subscription');
+    }
+
+    public function down(): void
+    {
+        $this->forge->dropTable('analytics_podcasts_by_subscription');
+    }
+}
diff --git a/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php b/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php
index 06db078734..c87a0193ed 100644
--- a/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php
+++ b/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php
@@ -38,7 +38,8 @@ class AddAnalyticsPodcastsProcedure extends Migration
             IN `p_filesize` INT UNSIGNED,
             IN `p_duration` DECIMAL(8,3) UNSIGNED,
             IN `p_age` INT UNSIGNED,
-            IN `p_new_listener` TINYINT(1) UNSIGNED
+            IN `p_new_listener` TINYINT(1) UNSIGNED,
+            IN `p_subscription_id` INT UNSIGNED
             )  MODIFIES SQL DATA
         DETERMINISTIC
         SQL SECURITY INVOKER
@@ -69,6 +70,12 @@ class AddAnalyticsPodcastsProcedure extends Migration
             INSERT INTO `{$prefix}analytics_podcasts_by_region`(`podcast_id`, `country_code`, `region_code`, `latitude`, `longitude`, `date`)
                 VALUES (p_podcast_id, p_country_code, p_region_code, p_latitude, p_longitude, @current_date)
                 ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
+
+            IF `p_subscription_id` THEN
+                INSERT INTO `{$prefix}analytics_podcasts_by_subscription`(`podcast_id`, `episode_id`, `subscription_id`, `date`)
+                VALUES (p_podcast_id, p_episode_id, p_subscription_id, @current_date)
+                ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
+            END IF;
         END IF;
         INSERT INTO `{$prefix}analytics_podcasts_by_player`(`podcast_id`, `service`, `app`, `device`, `os`, `is_bot`, `date`)
             VALUES (p_podcast_id, p_service, p_app, p_device, p_os, p_bot, @current_date)
diff --git a/modules/Analytics/Entities/AnalyticsPodcastsBySubscription.php b/modules/Analytics/Entities/AnalyticsPodcastsBySubscription.php
new file mode 100644
index 0000000000..c48ba98b46
--- /dev/null
+++ b/modules/Analytics/Entities/AnalyticsPodcastsBySubscription.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\Analytics\Entities;
+
+use CodeIgniter\Entity\Entity;
+
+/**
+ * @property int $podcast_id
+ * @property int $episode_id
+ * @property int $subscription_id
+ * @property Time $date
+ * @property int $hits
+ * @property Time $created_at
+ * @property Time $updated_at
+ */
+class AnalyticsPodcastsBySubscription extends Entity
+{
+    /**
+     * @var string[]
+     */
+    protected $dates = ['date', 'created_at', 'updated_at'];
+
+    /**
+     * @var array<string, string>
+     */
+    protected $casts = [
+        'podcast_id' => 'integer',
+        'episode_id' => 'integer',
+        'subscription_id' => 'integer',
+        'hits' => 'integer',
+    ];
+}
diff --git a/modules/Analytics/Helpers/analytics_helper.php b/modules/Analytics/Helpers/analytics_helper.php
index 1c76df0b08..351c8da77b 100644
--- a/modules/Analytics/Helpers/analytics_helper.php
+++ b/modules/Analytics/Helpers/analytics_helper.php
@@ -41,7 +41,9 @@ if (! function_exists('generate_episode_analytics_url')) {
     function generate_episode_analytics_url(
         int $podcastId,
         int $episodeId,
-        string $audioPath,
+        string $podcastHandle,
+        string $episodeSlug,
+        string $audioExtension,
         float $audioDuration,
         int $audioFileSize,
         int $audioFileHeaderSize,
@@ -66,7 +68,7 @@ if (! function_exists('generate_episode_analytics_url')) {
                     $publicationDate->getTimestamp(),
                 ),
             ),
-            $audioPath,
+            $podcastHandle . '/' . $episodeSlug . '.' . $audioExtension,
         );
     }
 }
@@ -263,7 +265,8 @@ if (! function_exists('podcast_hit')) {
         int $fileSize,
         float $duration,
         int $publicationTime,
-        string $serviceName
+        string $serviceName,
+        ?int $subscriptionId,
     ): void {
         $session = Services::session();
         $session->start();
@@ -353,7 +356,7 @@ if (! function_exists('podcast_hit')) {
                         ->save($podcastListenerHashId, $downloadsByUser, $secondsToMidnight);
 
                     $db->query(
-                        "CALL {$procedureName}(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);",
+                        "CALL {$procedureName}(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);",
                         [
                             $podcastId,
                             $episodeId,
@@ -370,6 +373,7 @@ if (! function_exists('podcast_hit')) {
                             $duration,
                             $age,
                             $newListener,
+                            $subscriptionId,
                         ],
                     );
                 }
diff --git a/modules/Analytics/Models/AnalyticsPodcastBySubscriptionModel.php b/modules/Analytics/Models/AnalyticsPodcastBySubscriptionModel.php
new file mode 100644
index 0000000000..d845a08f9a
--- /dev/null
+++ b/modules/Analytics/Models/AnalyticsPodcastBySubscriptionModel.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\Analytics\Models;
+
+use CodeIgniter\Model;
+use Modules\Analytics\Entities\AnalyticsPodcastsBySubscription;
+
+class AnalyticsPodcastBySubscriptionModel extends Model
+{
+    /**
+     * @var string
+     */
+    protected $table = 'analytics_podcasts_by_subscription';
+
+    /**
+     * @var string
+     */
+    protected $returnType = AnalyticsPodcastsBySubscription::class;
+
+    /**
+     * @var bool
+     */
+    protected $useSoftDeletes = false;
+
+    /**
+     * @var bool
+     */
+    protected $useTimestamps = false;
+
+    public function getNumberOfDownloadsLast3Months(int $podcastId, int $subscriptionId): int
+    {
+        $cacheName = "{$podcastId}_{$subscriptionId}_analytics_podcast_by_subscription";
+
+        if (
+            ! ($found = cache($cacheName))
+        ) {
+            $found = (int) ($this->builder()
+                ->selectSum('hits', 'total_hits')
+                ->where([
+                    'podcast_id' => $podcastId,
+                    'subscription_id' => $subscriptionId,
+                ])
+                ->where('`date` >= UTC_TIMESTAMP() - INTERVAL 3 month', null, false)
+                ->get()
+                ->getResultArray())[0]['total_hits'];
+
+            cache()
+                ->save($cacheName, $found, 600);
+        }
+
+        return $found;
+    }
+}
diff --git a/modules/Install/Controllers/InstallController.php b/modules/Install/Controllers/InstallController.php
index 1d51623ebb..0f80f081a8 100644
--- a/modules/Install/Controllers/InstallController.php
+++ b/modules/Install/Controllers/InstallController.php
@@ -259,6 +259,8 @@ class InstallController extends Controller
             ->latest();
         $migrations->setNamespace('Modules\Auth')
             ->latest();
+        $migrations->setNamespace('Modules\PremiumPodcasts')
+            ->latest();
         $migrations->setNamespace('Modules\Analytics')
             ->latest();
     }
diff --git a/modules/PremiumPodcasts/Config/Routes.php b/modules/PremiumPodcasts/Config/Routes.php
new file mode 100644
index 0000000000..f657acf46f
--- /dev/null
+++ b/modules/PremiumPodcasts/Config/Routes.php
@@ -0,0 +1,139 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\PremiumPodcasts\Config;
+
+$routes = service('routes');
+
+$routes->addPlaceholder('podcastHandle', '[a-zA-Z0-9\_]{1,32}');
+
+// Admin routes for subscriptions
+$routes->group(
+    config('Admin')
+        ->gateway,
+    [
+        'namespace' => 'Modules\PremiumPodcasts\Controllers',
+    ],
+    static function ($routes): void {
+        $routes->group('podcasts/(:num)/subscriptions', static function ($routes): void {
+            $routes->get('/', 'SubscriptionController::list/$1', [
+                'as' => 'subscription-list',
+                'filter' =>
+                    'permission:podcasts-view,podcast-manage_subscriptions',
+            ]);
+            $routes->get('add', 'SubscriptionController::add/$1', [
+                'as' => 'subscription-add',
+                'filter' => 'permission:podcast-manage_subscriptions',
+            ]);
+            $routes->post(
+                'add',
+                'SubscriptionController::attemptAdd/$1',
+                [
+                    'filter' =>
+                        'permission:podcast-manage_subscriptions',
+                ],
+            );
+            $routes->post('save-link', 'SubscriptionController::attemptLinkSave/$1', [
+                'as' => 'subscription-link-save',
+                'filter' => 'permission:podcast-manage_subscriptions',
+            ]);
+            // Subscription
+            $routes->group('(:num)', static function ($routes): void {
+                $routes->get('/', 'SubscriptionController::view/$1/$2', [
+                    'as' => 'subscription-view',
+                    'filter' =>
+                        'permission:podcast-manage_subscriptions',
+                ]);
+                $routes->get(
+                    'edit',
+                    'SubscriptionController::edit/$1/$2',
+                    [
+                        'as' => 'subscription-edit',
+                        'filter' =>
+                            'permission:podcast-manage_subscriptions',
+                    ],
+                );
+                $routes->post(
+                    'edit',
+                    'SubscriptionController::attemptEdit/$1/$2',
+                    [
+                        'as' => 'subscription-edit',
+                        'filter' =>
+                            'permission:podcast-manage_subscriptions',
+                    ],
+                );
+                $routes->get(
+                    'regenerate-token',
+                    'SubscriptionController::regenerateToken/$1/$2',
+                    [
+                        'as' => 'subscription-regenerate-token',
+                        'filter' =>
+                            'permission:podcast-manage_subscriptions',
+                    ]
+                );
+                $routes->get(
+                    'suspend',
+                    'SubscriptionController::suspend/$1/$2',
+                    [
+                        'as' => 'subscription-suspend',
+                        'filter' =>
+                            'permission:podcast-manage_subscriptions',
+                    ],
+                );
+                $routes->post(
+                    'suspend',
+                    'SubscriptionController::attemptSuspend/$1/$2',
+                    [
+                        'filter' =>
+                        'permission:podcast-manage_subscriptions',
+                    ],
+                );
+                $routes->get(
+                    'resume',
+                    'SubscriptionController::resume/$1/$2',
+                    [
+                        'as' => 'subscription-resume',
+                        'filter' =>
+                            'permission:podcast-manage_subscriptions',
+                    ],
+                );
+                $routes->get(
+                    'remove',
+                    'SubscriptionController::remove/$1/$2',
+                    [
+                        'as' => 'subscription-remove',
+                        'filter' =>
+                            'permission:podcast-manage_subscriptions',
+                    ],
+                );
+                $routes->post(
+                    'remove',
+                    'SubscriptionController::attemptRemove/$1/$2',
+                    [
+                        'filter' =>
+                            'permission:podcast-manage_subscriptions',
+                    ],
+                );
+            });
+        });
+    }
+);
+
+$routes->group(
+    '@(:podcastHandle)',
+    [
+        'namespace' => 'Modules\PremiumPodcasts\Controllers',
+    ],
+    static function ($routes): void {
+        $routes->get('unlock', 'LockController/$1', [
+            'as' => 'premium-podcast-unlock',
+        ]);
+        $routes->post('unlock', 'LockController::attemptUnlock/$1', [
+            'as' => 'premium-podcast-unlock',
+        ]);
+        $routes->get('lock', 'LockController::attemptLock/$1', [
+            'as' => 'premium-podcast-lock',
+        ]);
+    }
+);
diff --git a/modules/PremiumPodcasts/Config/Services.php b/modules/PremiumPodcasts/Config/Services.php
new file mode 100644
index 0000000000..c8fb03b49f
--- /dev/null
+++ b/modules/PremiumPodcasts/Config/Services.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\PremiumPodcasts\Config;
+
+use Config\Services as BaseService;
+use Modules\PremiumPodcasts\Models\SubscriptionModel;
+use Modules\PremiumPodcasts\PremiumPodcasts;
+
+class Services extends BaseService
+{
+    public static function premium_podcasts(?SubscriptionModel $subscriptionModel = null, bool $getShared = true)
+    {
+        if ($getShared) {
+            return self::getSharedInstance('premium_podcasts', $subscriptionModel);
+        }
+
+        $premiumPodcasts = new PremiumPodcasts();
+
+        $subscriptionModel ??= model(SubscriptionModel::class);
+
+        return $premiumPodcasts
+            ->setSubscriptionModel($subscriptionModel);
+    }
+}
diff --git a/modules/PremiumPodcasts/Controllers/LockController.php b/modules/PremiumPodcasts/Controllers/LockController.php
new file mode 100644
index 0000000000..8d57947eca
--- /dev/null
+++ b/modules/PremiumPodcasts/Controllers/LockController.php
@@ -0,0 +1,118 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\PremiumPodcasts\Controllers;
+
+use App\Controllers\BaseController;
+use App\Entities\Podcast;
+use App\Models\PodcastModel;
+use CodeIgniter\Exceptions\PageNotFoundException;
+use CodeIgniter\HTTP\RedirectResponse;
+use Modules\PremiumPodcasts\PremiumPodcasts;
+
+class LockController extends BaseController
+{
+    protected Podcast $podcast;
+
+    protected PremiumPodcasts $premiumPodcasts;
+
+    public function __construct()
+    {
+        $this->premiumPodcasts = service('premium_podcasts');
+    }
+
+    public function _remap(string $method, string ...$params): mixed
+    {
+        if ($params === []) {
+            throw PageNotFoundException::forPageNotFound();
+        }
+
+        if (($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) === null) {
+            throw PageNotFoundException::forPageNotFound();
+        }
+
+        $this->podcast = $podcast;
+
+        return $this->{$method}();
+    }
+
+    public function index(): string
+    {
+        $locale = service('request')
+            ->getLocale();
+        $cacheName =
+            "page_podcast#{$this->podcast->id}_{$locale}_unlock" .
+            (can_user_interact() ? '_authenticated' : '');
+
+        if (! ($cachedView = cache($cacheName))) {
+            $data = [
+                // TODO: metatags for locked premium podcasts
+                'metatags' => '',
+                'podcast' => $this->podcast,
+            ];
+
+            helper('form');
+
+            if (can_user_interact()) {
+                return view('podcast/unlock', $data);
+            }
+
+            // The page cache is set to a decade so it is deleted manually upon podcast update
+            return view('podcast/unlock', $data, [
+                'cache' => DECADE,
+                'cache_name' => $cacheName,
+            ]);
+        }
+
+        return $cachedView;
+    }
+
+    public function attemptUnlock(): RedirectResponse
+    {
+        $rules = [
+            'token' => 'required',
+        ];
+
+        if (! $this->validate($rules)) {
+            return redirect()->back()
+                ->withInput()
+                ->with('errors', $this->validator->getErrors());
+        }
+
+        $token = (string) $this->request->getPost('token');
+
+        // attempt unlocking the podcast with the token
+        if (! $this->premiumPodcasts->unlock($this->podcast->handle, $token)) {
+            // bad key or subscription is not active
+            return redirect()->back()
+                ->withInput()
+                ->with('error', lang('PremiumPodcasts.messages.unlockBadAttempt'));
+        }
+
+        $redirectURL = session('redirect_url') ?? site_url('/');
+        unset($_SESSION['redirect_url']);
+
+        return redirect()->to($redirectURL)
+            ->withCookies()
+            ->with('message', lang('PremiumPodcasts.messages.unlockSuccess'));
+    }
+
+    public function attemptLock(): RedirectResponse
+    {
+        $this->premiumPodcasts->lock($this->podcast->handle);
+
+        $redirectURL = session('redirect_url') ?? site_url('/');
+        unset($_SESSION['redirect_url']);
+
+        return redirect()->to($redirectURL)
+            ->withCookies()
+            ->with('message', lang('PremiumPodcasts.messages.lockSuccess'));
+    }
+}
diff --git a/modules/PremiumPodcasts/Controllers/SubscriptionController.php b/modules/PremiumPodcasts/Controllers/SubscriptionController.php
new file mode 100644
index 0000000000..d5147f231d
--- /dev/null
+++ b/modules/PremiumPodcasts/Controllers/SubscriptionController.php
@@ -0,0 +1,447 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\PremiumPodcasts\Controllers;
+
+use App\Entities\Podcast;
+use App\Models\PodcastModel;
+use CodeIgniter\Email\Email;
+use CodeIgniter\Exceptions\PageNotFoundException;
+use CodeIgniter\HTTP\RedirectResponse;
+use CodeIgniter\I18n\Time;
+use Modules\Admin\Controllers\BaseController;
+use Modules\PremiumPodcasts\Entities\Subscription;
+use Modules\PremiumPodcasts\Models\SubscriptionModel;
+
+class SubscriptionController extends BaseController
+{
+    protected Podcast $podcast;
+
+    protected Subscription $subscription;
+
+    public function _remap(string $method, string ...$params): mixed
+    {
+        if ($params === []) {
+            throw PageNotFoundException::forPageNotFound();
+        }
+
+        if (($podcast = (new PodcastModel())->getPodcastById((int) $params[0])) === null) {
+            throw PageNotFoundException::forPageNotFound();
+        }
+
+        $this->podcast = $podcast;
+
+        if (count($params) <= 1) {
+            return $this->{$method}();
+        }
+
+        if (($this->subscription = (new SubscriptionModel())->getSubscriptionById((int) $params[1])) === null) {
+            throw PageNotFoundException::forPageNotFound();
+        }
+
+        return $this->{$method}();
+    }
+
+    public function list(): string
+    {
+        $data = [
+            'podcast' => $this->podcast,
+        ];
+
+        helper('form');
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+        ]);
+        return view('subscription/list', $data);
+    }
+
+    public function attemptLinkSave(): RedirectResponse
+    {
+        $rules = [
+            'subscription_link' => 'valid_url_strict|permit_empty',
+        ];
+
+        if (! $this->validate($rules)) {
+            return redirect()->back()
+                ->withInput()
+                ->with('errors', $this->validator->getErrors());
+        }
+
+        if (($subscriptionLink = $this->request->getPost('subscription_link')) === '') {
+            service('settings')
+                ->forget('Subscription.link', 'podcast:' . $this->podcast->id);
+
+            return redirect()->route('subscription-list', [$this->podcast->id])->with(
+                'message',
+                lang('Subscription.messages.linkRemoveSuccess')
+            );
+        }
+
+        service('settings')
+            ->set('Subscription.link', $subscriptionLink, 'podcast:' . $this->podcast->id);
+
+        return redirect()->route('subscription-list', [$this->podcast->id])->with(
+            'message',
+            lang('Subscription.messages.linkSaveSuccess')
+        );
+    }
+
+    public function view(): string
+    {
+        $data = [
+            'podcast' => $this->podcast,
+            'subscription' => $this->subscription,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+            1 => '#' . $this->subscription->id,
+        ]);
+        return view('subscription/view', $data);
+    }
+
+    public function add(): string
+    {
+        helper('form');
+
+        $data = [
+            'podcast' => $this->podcast,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+        ]);
+        return view('subscription/add', $data);
+    }
+
+    public function attemptAdd(): RedirectResponse
+    {
+        helper('text');
+
+        $expiresAt = null;
+        $expirationDate = $this->request->getPost('expiration_date');
+        if ($expirationDate) {
+            $expiresAt = Time::createFromFormat(
+                'Y-m-d H:i',
+                $expirationDate,
+                $this->request->getPost('client_timezone'),
+            )->setTimezone(app_timezone());
+        }
+
+        $newSubscription = new Subscription([
+            'podcast_id' => $this->podcast->id,
+            'email' => $this->request->getPost('email'),
+            'token' => hash('sha256', $rawToken = random_string('alnum', 8)),
+            'expires_at' => $expiresAt,
+            'created_by' => user_id(),
+            'updated_by' => user_id(),
+        ]);
+
+        $db = db_connect();
+        $db->transStart();
+
+        $subscriptionModel = new SubscriptionModel();
+        if (! $subscriptionModel->insert($newSubscription)) {
+            $db->transRollback();
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $subscriptionModel->errors());
+        }
+
+        /** @var Email $email */
+        $email = service('email');
+
+        if (! $email->setTo($newSubscription->email)
+            ->setSubject(lang('Subscription.emails.welcome_subject', [
+                'podcastTitle' => $this->podcast->title,
+            ], $this->podcast->language_code))
+            ->setMessage(view('subscription/email/welcome', [
+                'subscription' => $newSubscription,
+                'token' => $rawToken,
+            ]))->setMailType('html')
+            ->send()) {
+            $db->transRollback();
+            return redirect()->route('subscription-list', [$this->podcast->id])->with(
+                'errors',
+                [lang('Subscription.messages.addError'), $email->printDebugger([])]
+            );
+        }
+
+        $db->transComplete();
+
+        return redirect()->route('subscription-list', [$this->podcast->id])->with(
+            'message',
+            lang('Subscription.messages.addSuccess', [
+                'subscriber' => $newSubscription->email,
+            ])
+        );
+    }
+
+    public function regenerateToken(): RedirectResponse
+    {
+        helper('text');
+
+        $this->subscription->token = hash('sha256', $rawToken = random_string('alnum', 8));
+        $this->subscription->updated_by = user_id();
+
+        $db = db_connect();
+
+        $db->transStart();
+
+        $subscriptionModel = new SubscriptionModel();
+        if (! $subscriptionModel->update($this->subscription->id, $this->subscription)) {
+            $db->transRollback();
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $subscriptionModel->errors());
+        }
+
+        /** @var Email $email */
+        $email = service('email');
+
+        if (! $email->setTo($this->subscription->email)
+            ->setSubject(lang('Subscription.emails.reset_subject', [], $this->podcast->language_code))
+            ->setMessage(view('subscription/email/reset', [
+                'subscription' => $this->subscription,
+                'token' => $rawToken,
+            ]))->setMailType('html')
+            ->send()) {
+            $db->transRollback();
+            return redirect()->route('subscription-list', [$this->podcast->id])->with(
+                'errors',
+                [lang('Subscription.messages.regenerateTokenError'), $email->printDebugger([])]
+            );
+        }
+
+        $db->transComplete();
+
+        return redirect()->route('subscription-list', [$this->podcast->id])->with(
+            'message',
+            lang('Subscription.messages.regenerateTokenSuccess', [
+                'subscriber' => $this->subscription->email,
+            ])
+        );
+    }
+
+    public function edit(): string
+    {
+        helper('form');
+
+        $data = [
+            'podcast' => $this->podcast,
+            'subscription' => $this->subscription,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+            1 => '#' . $this->subscription->id,
+        ]);
+        return view('subscription/edit', $data);
+    }
+
+    public function attemptEdit(): RedirectResponse
+    {
+        $expiresAt = null;
+        $expirationDate = $this->request->getPost('expiration_date');
+        if ($expirationDate) {
+            $expiresAt = Time::createFromFormat(
+                'Y-m-d H:i',
+                $expirationDate,
+                $this->request->getPost('client_timezone'),
+            )->setTimezone(app_timezone());
+        }
+
+        $this->subscription->expires_at = $expiresAt;
+
+        $db = db_connect();
+        $db->transStart();
+
+        $subscriptionModel = new SubscriptionModel();
+        if (! $subscriptionModel->update($this->subscription->id, $this->subscription)) {
+            $db->transRollback();
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $subscriptionModel->errors());
+        }
+
+        /** @var Email $email */
+        $email = service('email');
+
+        if (! $email->setTo($this->subscription->email)
+            ->setSubject(lang('Subscription.emails.edited_subject', [], $this->podcast->language_code))
+            ->setMessage(view('subscription/email/edited', [
+                'subscription' => $this->subscription,
+            ]))->setMailType('html')
+            ->send()) {
+            $db->transRollback();
+            return redirect()->route('subscription-list', [$this->podcast->id])->with(
+                'errors',
+                [lang('Subscription.messages.editError'), $email->printDebugger([])]
+            );
+        }
+
+        $db->transComplete();
+
+        return redirect()->route('subscription-list', [$this->podcast->id])->with(
+            'message',
+            lang('Subscription.messages.editSuccess', [
+                'subscriber' => $this->subscription->email,
+            ])
+        );
+    }
+
+    public function suspend(): string
+    {
+        helper('form');
+
+        $data = [
+            'podcast' => $this->podcast,
+            'subscription' => $this->subscription,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+            1 => '#' . $this->subscription->id,
+        ]);
+        return view('subscription/suspend', $data);
+    }
+
+    public function attemptSuspend(): RedirectResponse
+    {
+        $db = db_connect();
+        $db->transStart();
+
+        $this->subscription->suspend($this->request->getPost('reason'));
+        $subscriptionModel = new SubscriptionModel();
+        if (! $subscriptionModel->update($this->subscription->id, $this->subscription)) {
+            return redirect()
+                ->back()
+                ->with('errors', $subscriptionModel->errors());
+        }
+
+        /** @var Email $email */
+        $email = service('email');
+
+        if (! $email->setTo($this->subscription->email)
+            ->setSubject(lang('Subscription.emails.suspended_subject', [], $this->podcast->language_code))
+            ->setMessage(view('subscription/email/suspended', [
+                'subscription' => $this->subscription,
+            ]))->setMailType('html')
+            ->send()) {
+            $db->transRollback();
+            return redirect()->route('subscription-list', [$this->podcast->id])->with(
+                'errors',
+                [lang('Subscription.messages.suspendError'), $email->printDebugger([])]
+            );
+        }
+
+        $db->transComplete();
+
+        return redirect()->route('subscription-list', [$this->podcast->id])->with(
+            'messages',
+            lang('Subscription.messages.suspendSuccess', [
+                'subscriber' => $this->subscription->email,
+            ])
+        );
+    }
+
+    public function resume(): RedirectResponse
+    {
+        $db = db_connect();
+        $db->transStart();
+
+        $this->subscription->resume();
+
+        $subscriptionModel = new SubscriptionModel();
+        if (! $subscriptionModel->update($this->subscription->id, $this->subscription)) {
+            return redirect()
+                ->back()
+                ->with('errors', $subscriptionModel->errors());
+        }
+
+        /** @var Email $email */
+        $email = service('email');
+
+        if (! $email->setTo($this->subscription->email)
+            ->setSubject(lang('Subscription.emails.resumed_subject', [], $this->podcast->language_code))
+            ->setMessage(view('subscription/email/resumed', [
+                'subscription' => $this->subscription,
+            ]))->setMailType('html')
+            ->send()) {
+            $db->transRollback();
+            return redirect()->route('subscription-list', [$this->podcast->id])->with(
+                'errors',
+                [lang('Subscription.messages.resumeError'), $email->printDebugger([])]
+            );
+        }
+
+        $db->transComplete();
+
+        return redirect()->route('subscription-list', [$this->podcast->id])->with(
+            'message',
+            lang('Subscription.messages.resumeSuccess', [
+                'subscriber' => $this->subscription->email,
+            ])
+        );
+    }
+
+    public function remove(): string
+    {
+        helper('form');
+
+        $data = [
+            'podcast' => $this->podcast,
+            'subscription' => $this->subscription,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+            1 => '#' . $this->subscription->id,
+        ]);
+        return view('subscription/delete', $data);
+    }
+
+    public function attemptRemove(): RedirectResponse
+    {
+        $db = db_connect();
+        $db->transStart();
+
+        (new SubscriptionModel())->delete($this->subscription->id);
+
+        /** @var Email $email */
+        $email = service('email');
+
+        if (! $email->setTo($this->subscription->email)
+            ->setSubject(lang('Subscription.emails.removed_subject', [], $this->podcast->language_code))
+            ->setMessage(view('subscription/email/removed', [
+                'subscription' => $this->subscription,
+            ]))->setMailType('html')
+            ->send()) {
+            $db->transRollback();
+            return redirect()->route('subscription-list', [$this->podcast->id])->with(
+                'errors',
+                [lang('Subscription.messages.removeError'), $email->printDebugger([])]
+            );
+        }
+
+        $db->transComplete();
+
+        return redirect()->route('subscription-list', [$this->podcast->id])->with(
+            'messages',
+            lang('Subscription.messages.removeSuccess', [
+                'subscriber' => $this->subscription->email,
+            ])
+        );
+    }
+}
diff --git a/modules/PremiumPodcasts/Database/Migrations/2022-07-07-120000_add_subscriptions.php b/modules/PremiumPodcasts/Database/Migrations/2022-07-07-120000_add_subscriptions.php
new file mode 100644
index 0000000000..ae4b91abd2
--- /dev/null
+++ b/modules/PremiumPodcasts/Database/Migrations/2022-07-07-120000_add_subscriptions.php
@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2021 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\PremiumPodcasts\Database\Migrations;
+
+use CodeIgniter\Database\Migration;
+
+class AddSubscriptions extends Migration
+{
+    public function up(): void
+    {
+        $this->forge->addField([
+            'id' => [
+                'type' => 'INT',
+                'unsigned' => true,
+                'auto_increment' => true,
+            ],
+            'podcast_id' => [
+                'type' => 'INT',
+                'unsigned' => true,
+            ],
+            'email' => [
+                'type' => 'VARCHAR',
+                'constraint' => 255,
+            ],
+            'token' => [
+                'type' => 'VARCHAR',
+                'constraint' => 64,
+            ],
+            'status' => [
+                'type' => 'ENUM',
+                'constraint' => ['active', 'suspended'],
+                'default' => 'active',
+            ],
+            'status_message' => [
+                'type' => 'VARCHAR',
+                'constraint' => 255,
+                'null' => true,
+            ],
+            'expires_at' => [
+                'type' => 'DATETIME',
+                'null' => true,
+            ],
+            'created_by' => [
+                'type' => 'INT',
+                'unsigned' => true,
+            ],
+            'updated_by' => [
+                'type' => 'INT',
+                'unsigned' => true,
+            ],
+            'created_at' => [
+                'type' => 'DATETIME',
+            ],
+            'updated_at' => [
+                'type' => 'DATETIME',
+            ],
+        ]);
+
+        $this->forge->addKey('id', true);
+        $this->forge->addUniqueKey(['podcast_id', 'email']);
+        $this->forge->addUniqueKey('token');
+        $this->forge->addForeignKey('podcast_id', 'podcasts', 'id', '', 'CASCADE');
+        $this->forge->addForeignKey('created_by', 'users', 'id');
+        $this->forge->addForeignKey('updated_by', 'users', 'id');
+        $this->forge->createTable('subscriptions');
+    }
+
+    public function down(): void
+    {
+        $this->forge->dropTable('subscriptions');
+    }
+}
diff --git a/modules/PremiumPodcasts/Entities/Subscription.php b/modules/PremiumPodcasts/Entities/Subscription.php
new file mode 100644
index 0000000000..3ea10454c8
--- /dev/null
+++ b/modules/PremiumPodcasts/Entities/Subscription.php
@@ -0,0 +1,123 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\PremiumPodcasts\Entities;
+
+use App\Entities\Podcast;
+use App\Models\PodcastModel;
+use CodeIgniter\Entity\Entity;
+use CodeIgniter\I18n\Time;
+use Modules\Analytics\Models\AnalyticsPodcastBySubscriptionModel;
+use RuntimeException;
+
+/**
+ * @property int $id
+ * @property int $podcast_id
+ * @property Podcast|null $podcast
+ * @property string $email
+ * @property string $token
+ * @property string $status
+ * @property string|null $status_message
+ * @property Time $expires_at
+ * @property int $downloads_last_3_months
+ *
+ * @property int $created_by
+ * @property int $updated_by
+ * @property Time $created_at
+ * @property Time $updated_at
+ */
+class Subscription extends Entity
+{
+    protected ?Podcast $podcast = null;
+
+    /**
+     * @var string[]
+     */
+    protected $dates = ['expires_at', 'created_at', 'updated_at'];
+
+    /**
+     * @var array<string, string>
+     */
+    protected $casts = [
+        'id' => 'integer',
+        'podcast_id' => 'integer',
+        'email' => 'string',
+        'token' => 'string',
+        'status' => 'string',
+        'status_message' => '?string',
+        'created_by' => 'integer',
+        'updated_by' => 'integer',
+    ];
+
+    public function getStatus(): string
+    {
+        return ($this->expires_at !== null && $this->expires_at->isBefore(
+            Time::now()
+        )) ? 'expired' : $this->attributes['status'];
+    }
+
+    /**
+     * Suspend a subscription.
+     *
+     * @return $this
+     */
+    public function suspend(string $reason): static
+    {
+        $this->attributes['status'] = 'suspended';
+        $this->attributes['status_message'] = $reason;
+
+        return $this;
+    }
+
+    /**
+     * Resumes a subscription / unSuspend.
+     *
+     * @return $this
+     */
+    public function resume(): static
+    {
+        $this->attributes['status'] = 'active';
+        $this->attributes['status_message'] = null;
+
+        return $this;
+    }
+
+    /**
+     * Checks to see if a subscription has been suspended.
+     */
+    public function isSuspended(): bool
+    {
+        return isset($this->attributes['status']) && $this->attributes['status'] === 'suspended';
+    }
+
+    /**
+     * Returns the subscription's podcast
+     */
+    public function getPodcast(): ?Podcast
+    {
+        if ($this->podcast_id === null) {
+            throw new RuntimeException('Subscription must have a podcast_id before getting podcast.');
+        }
+
+        if (! $this->podcast instanceof Podcast) {
+            $this->podcast = (new PodcastModel())->getPodcastById($this->podcast_id);
+        }
+
+        return $this->podcast;
+    }
+
+    public function getDownloadsLast3Months(): int
+    {
+        return (new AnalyticsPodcastBySubscriptionModel())->getNumberOfDownloadsLast3Months(
+            $this->podcast_id,
+            $this->id
+        );
+    }
+}
diff --git a/modules/PremiumPodcasts/Filters/PodcastUnlockFilter.php b/modules/PremiumPodcasts/Filters/PodcastUnlockFilter.php
new file mode 100644
index 0000000000..a46348e5fa
--- /dev/null
+++ b/modules/PremiumPodcasts/Filters/PodcastUnlockFilter.php
@@ -0,0 +1,86 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\PremiumPodcasts\Filters;
+
+use App\Models\EpisodeModel;
+use CodeIgniter\Filters\FilterInterface;
+use CodeIgniter\HTTP\RequestInterface;
+use CodeIgniter\HTTP\ResponseInterface;
+use CodeIgniter\Router\Router;
+use Config\App;
+use Modules\PremiumPodcasts\PremiumPodcasts;
+use Myth\Auth\Authentication\AuthenticationBase;
+
+class PodcastUnlockFilter implements FilterInterface
+{
+    /**
+     * Verifies that a user is logged in, or redirects to login.
+     *
+     * @param array|null $params
+     *
+     * @return mixed
+     */
+    public function before(RequestInterface $request, $params = null)
+    {
+        if (! function_exists('is_unlocked')) {
+            helper('premium_podcasts');
+        }
+
+        $current = (string) current_url(true)
+            ->setHost('')
+            ->setScheme('')
+            ->stripQuery('token');
+
+        $config = config(App::class);
+        if ($config->forceGlobalSecureRequests) {
+            // Remove "https:/"
+            $current = substr($current, 7);
+        }
+
+        /** @var Router $router */
+        $router = service('router');
+        $routerParams = $router->params();
+
+        if ($routerParams === []) {
+            return;
+        }
+
+        // no need to go through the unlock form if user is connected
+        /** @var AuthenticationBase $auth */
+        $auth = service('authentication');
+        if ($auth->isLoggedIn()) {
+            return;
+        }
+
+        // Make sure this isn't already a premium podcast route
+        if ($current === route_to('premium-podcast-unlock', $routerParams[0])) {
+            return;
+        }
+
+        // Make sure that public episodes are still accessible
+        if ($routerParams >= 2 && ($episode = (new EpisodeModel())->getEpisodeBySlug(
+            $routerParams[0],
+            $routerParams[1]
+        )) && ! $episode->is_premium) {
+            return;
+        }
+
+        // if podcast is locked then send to the unlock form
+        /** @var PremiumPodcasts $premiumPodcasts */
+        $premiumPodcasts = service('premium_podcasts');
+        if (! $premiumPodcasts->check($routerParams[0])) {
+            session()->set('redirect_url', current_url());
+
+            return redirect()->route('premium-podcast-unlock', [$routerParams[0]]);
+        }
+    }
+
+    /**
+     * @param array|null $arguments
+     */
+    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
+    {
+    }
+}
diff --git a/modules/PremiumPodcasts/Helpers/premium_podcasts_helper.php b/modules/PremiumPodcasts/Helpers/premium_podcasts_helper.php
new file mode 100644
index 0000000000..46794c85f5
--- /dev/null
+++ b/modules/PremiumPodcasts/Helpers/premium_podcasts_helper.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+use Modules\PremiumPodcasts\Entities\Subscription;
+use Modules\PremiumPodcasts\PremiumPodcasts;
+
+if (! function_exists('is_podcast_unlocked')) {
+    function is_unlocked(string $podcastHandle): bool
+    {
+        /** @var PremiumPodcasts $premiumPodcast */
+        $premiumPodcast = service('premium_podcasts');
+        return $premiumPodcast->check($podcastHandle);
+    }
+}
+
+if (! function_exists('subscription')) {
+    /**
+     * Returns the Subscription instance for the currently active subscription.
+     */
+    function subscription(string $podcastHandle): ?Subscription
+    {
+        /** @var PremiumPodcasts $premiumPodcast */
+        $premiumPodcast = service('premium_podcasts');
+        $premiumPodcast->check($podcastHandle);
+
+        return $premiumPodcast->subscription($podcastHandle);
+    }
+}
diff --git a/modules/PremiumPodcasts/Language/en/PremiumPodcasts.php b/modules/PremiumPodcasts/Language/en/PremiumPodcasts.php
new file mode 100644
index 0000000000..18c0dd4e4e
--- /dev/null
+++ b/modules/PremiumPodcasts/Language/en/PremiumPodcasts.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'podcast_is_premium' => 'Podcast contains premium episodes',
+    'episode_is_premium' => 'Episode is premium, only available to premium subscribers',
+    'unlock_episode' => 'This episode is for premium subscribers only. Click to unlock it!',
+    'banner_unlock' => 'This podcast contains premium episodes, only available to premium subscribers.',
+    'banner_lock' => 'Podcast is unlocked, enjoy the premium episodes!',
+    'subscribe' => 'Subscribe',
+    'lock' => 'Lock',
+    'unlock' => 'Unlock',
+    'unlock_form' => [
+        'title' => 'Premium content',
+        'subtitle' => 'This podcast contains locked premium episodes! Do you have the key to unlock them?',
+        'token' => 'Enter your key',
+        'token_hint' => 'If you are subscribed to {podcastTitle}, you may copy the key that was sent to you via email and paste it here.',
+        'submit' => 'Unlock all episodes!',
+        'call_to_action' => 'Unlock all episodes of {podcastTitle}:',
+        'subscribe_cta' => 'Subscribe now!',
+    ],
+    'messages' => [
+        'unlockSuccess' => 'Podcast was successfully unlocked! Enjoy the premium episodes!',
+        'unlockBadAttempt' => 'Your key does not seem to be working…',
+        'lockSuccess' => 'Podcast was successfully locked!',
+    ],
+];
diff --git a/modules/PremiumPodcasts/Language/en/Subscription.php b/modules/PremiumPodcasts/Language/en/Subscription.php
new file mode 100644
index 0000000000..7371c8ab0c
--- /dev/null
+++ b/modules/PremiumPodcasts/Language/en/Subscription.php
@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'podcast_subscriptions' => 'Podcast subscriptions',
+    'add' => 'New subscription',
+    'view' => 'View subscription',
+    'edit' => 'Edit subscription',
+    'regenerate_token' => 'Regenerate token',
+    'suspend' => 'Suspend subscription',
+    'resume' => 'Resume subscription',
+    'delete' => 'Delete subscription',
+    'status' => [
+        'active' => 'Active',
+        'suspended' => 'Suspended',
+        'expired' => 'Expired',
+    ],
+    'list' => [
+        'number' => 'Number',
+        'email' => 'Email',
+        'expiration_date' => 'Expiration date',
+        'unlimited' => 'Unlimited',
+        'downloads' => 'Downloads',
+        'status' => 'Status',
+    ],
+    'form' => [
+        'email' => 'Email',
+        'expiration_date' => 'Expiration date',
+        'expiration_date_hint' => 'The date and time at which the subscription expires. Leave empty for an unlimited subscription.',
+        'submit_add' => 'Add subscription',
+        'submit_edit' => 'Edit subscription',
+    ],
+    'form_link_add' => [
+        'link' => 'Subscription page link',
+        'link_hint' => 'This will add a call to action in the website inviting listeners to subscribe to the podcast.',
+        'submit' => 'Save link',
+    ],
+    'suspend_form' => [
+        'disclaimer' => 'Suspending the subscription will restrict the subscriber from having access to the premium content. You will still be able to lift the suspension afterwards.',
+        'reason' => 'Reason',
+        'reason_placeholder' => 'Why are you suspending the subscription?',
+        "submit" => 'Suspend subscription',
+    ],
+    'delete_form' => [
+        'disclaimer' => 'Deleting {subscriber}\'s subscription will remove all analytics data associated with it.',
+        'understand' => 'I understand, remove the subscription permanently',
+        'submit' => 'Remove subscription',
+    ],
+    'messages' => [
+        'addSuccess' => 'New subscription added! A welcome email was sent to {subscriber}.',
+        'addError' => 'Subscription could not be added.',
+        'editSuccess' => 'Subscription expiry date was updated! An email was sent to {subscriber}.',
+        'editError' => 'Subscription could not be edited.',
+        'regenerateTokenSuccess' => 'Token regenerated! An email was sent to {subscriber} with the new token.',
+        'regenerateTokenError' => 'Token could not be regenerated.',
+        'removeSuccess' => 'Subscription was canceled! An email was sent to {subscriber} to tell him.',
+        'removeError' => 'Subscription could not be canceled.',
+        'suspendSuccess' => 'Subscription was suspended! An email was sent to {subscriber}.',
+        'suspendError' => 'Subscription could not be suspended.',
+        'resumeSuccess' => 'Subscription was resumed! An email was sent to {subscriber}.',
+        'resumeError' => 'Subscription could not be resumed.',
+        'linkSaveSuccess' => 'Subscription link was saved successfully! It will appear in the website as a Call To Action!',
+        'linkRemoveSuccess' => 'Subscription link was removed successfully!',
+    ],
+    'emails' => [
+        'greeting' => 'Hey,',
+        'token' => 'Your token: {0}',
+        'unique_feed_link' => 'Your unique feed link: {0}',
+        'how_to_use' => 'How to use?',
+        'two_ways' => 'You have two ways of unlocking the premium episodes:',
+        'import_into_app' => 'Copy your unique feed url inside your favourite podcast app (import it as a private feed to prevent exposing your credentials).',
+        'go_to_website' => 'Go to {podcastWebsite}\'s website and unlock the podcast with your token.',
+        'welcome_subject' => 'Welcome to {podcastTitle}',
+        'welcome' => 'You have subscribed to {podcastTitle}, thank you and welcome aboard!',
+        'welcome_token_title' => 'Here are your credentials to unlock the podcast\'s premium episodes:',
+        'welcome_expires' => 'Your subscription was set to expire on {0}.',
+        'welcome_never_expires' => 'Your subscription was set to never expire.',
+        'reset_subject' => 'Your token was reset!',
+        'reset_token' => 'Your access to {podcastTitle} has been reset!',
+        'reset_token_title' => 'New credentials have been generated for you to unlock the podcast\'s premium episodes:',
+        'edited_subject' => 'Your subscription has been updated!',
+        'edited_expires' => 'Your subscription for {podcastTitle} was set to expire on {expiresAt}.',
+        'edited_never_expires' => 'Your subscription for {podcastTitle} was set to never expire!',
+        'suspended_subject' => 'Your subscription has been suspended!',
+        'suspended' => 'Your subscription for {podcastTitle} has been suspended! You can no longer access the podcast\'s premium episodes.',
+        'suspended_reason' => 'That is for the following reason: {0}',
+        'resumed_subject' => 'Your subscription has been resumed!',
+        'resumed' => 'Your subscription for {podcastTitle} has been resumed! You may access the podcast\'s premium episodes again.',
+        'removed_subject' => 'Your subscription has been removed!',
+        'removed' => 'Your subscription for {podcastTitle} has been removed! You no longer have access to the podcast\'s premium episodes.',
+        'footer' => '{castopod} hosted on {host}',
+    ],
+];
diff --git a/modules/PremiumPodcasts/Models/SubscriptionModel.php b/modules/PremiumPodcasts/Models/SubscriptionModel.php
new file mode 100644
index 0000000000..5f79271ac1
--- /dev/null
+++ b/modules/PremiumPodcasts/Models/SubscriptionModel.php
@@ -0,0 +1,141 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * Class SoundbiteModel Model for podcasts_soundbites table in database
+ *
+ * @copyright  2020 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\PremiumPodcasts\Models;
+
+use CodeIgniter\Model;
+use Modules\PremiumPodcasts\Entities\Subscription;
+
+class SubscriptionModel extends Model
+{
+    /**
+     * @var string
+     */
+    protected $table = 'subscriptions';
+
+    /**
+     * @var string
+     */
+    protected $primaryKey = 'id';
+
+    /**
+     * @var string[]
+     */
+    protected $allowedFields = [
+        'id',
+        'podcast_id',
+        'email',
+        'token',
+        'status',
+        'status_message',
+        'expires_at',
+        'created_by',
+        'updated_by',
+    ];
+
+    /**
+     * @noRector
+     */
+    protected $returnType = Subscription::class;
+
+    /**
+     * @var bool
+     */
+    protected $useSoftDeletes = false;
+
+    /**
+     * @var bool
+     */
+    protected $useTimestamps = true;
+
+    /**
+     * @var string[]
+     */
+    protected $afterUpdate = ['clearCache'];
+
+    /**
+     * @var string[]
+     */
+    protected $beforeDelete = ['clearCache'];
+
+    public function getSubscriptionById(int $subscriptionId): ?Subscription
+    {
+        $cacheName = "subscription#{$subscriptionId}";
+        if (! ($found = cache($cacheName))) {
+            $found = $this->find($subscriptionId);
+
+            cache()
+                ->save($cacheName, $found, DECADE);
+        }
+
+        return $found;
+    }
+
+    /**
+     * @return Subscription[]
+     */
+    public function getPodcastSubscriptions(int $podcastId): array
+    {
+        $cacheName = "podcast#{$podcastId}_subscriptions";
+        if (! ($found = cache($cacheName))) {
+            $found = $this->where('podcast_id', $podcastId)
+                ->findAll();
+
+            cache()
+                ->save($cacheName, $found, DECADE);
+        }
+
+        return $found;
+    }
+
+    /**
+     * @param string $token plain-text token to be encrypted and matched against encrypted tokens in database
+     */
+    public function validateSubscription(int|string $podcastIdOrHandle, string $token): ?Subscription
+    {
+        $subscriptionModel = $this;
+
+        if (is_int($podcastIdOrHandle)) {
+            $this->where('id', $podcastIdOrHandle);
+        } else {
+            $this->select('subscriptions.*')
+                ->where('handle', $podcastIdOrHandle)
+                ->join('podcasts', 'podcasts.id = subscriptions.podcast_id');
+        }
+
+        return $subscriptionModel
+            ->where([
+                'token' => hash('sha256', $token),
+                'status' => 'active',
+                'expires_at' => null,
+            ])
+            ->orWhere('`expires_at` > UTC_TIMESTAMP()', null, false)
+            ->first();
+    }
+
+    /**
+     * @param mixed[] $data
+     *
+     * @return mixed[]
+     */
+    protected function clearCache(array $data): array
+    {
+        $subscription = (new self())->find(is_array($data['id']) ? $data['id'][0] : $data['id']);
+
+        cache()
+            ->delete("subscription#{$subscription->id}");
+        cache()
+            ->delete("podcast#{$subscription->podcast_id}_subscriptions");
+
+        return $data;
+    }
+}
diff --git a/modules/PremiumPodcasts/PremiumPodcasts.php b/modules/PremiumPodcasts/PremiumPodcasts.php
new file mode 100644
index 0000000000..e1469d973d
--- /dev/null
+++ b/modules/PremiumPodcasts/PremiumPodcasts.php
@@ -0,0 +1,114 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\PremiumPodcasts;
+
+use CodeIgniter\Events\Events;
+use Modules\PremiumPodcasts\Entities\Subscription;
+use Modules\PremiumPodcasts\Models\SubscriptionModel;
+
+class PremiumPodcasts
+{
+    protected SubscriptionModel $subscriptionModel;
+
+    /**
+     * @var array<string, Subscription|null>
+     */
+    protected $subscriptions = [];
+
+    public function setSubscriptionModel(SubscriptionModel $subscriptionModel): self
+    {
+        $this->subscriptionModel = $subscriptionModel;
+
+        return $this;
+    }
+
+    public function unlock(string $podcastHandle, string $token): bool
+    {
+        $subscription = $this->subscriptionModel->validateSubscription($podcastHandle, $token);
+
+        if (! $subscription instanceof Subscription) {
+            $this->subscriptions[$podcastHandle] = null;
+
+            return false;
+        }
+
+        $this->subscriptions[$podcastHandle] = $subscription;
+
+        $session = session();
+        $session->set("{$podcastHandle}:subscription", $subscription);
+
+        Events::trigger('unlock', $podcastHandle, $subscription);
+
+        return true;
+    }
+
+    public function lock(string $podcastHandle): bool
+    {
+        if (! $this->isUnlocked($podcastHandle)) {
+            return true;
+        }
+
+        $this->subscriptions[$podcastHandle] = null;
+
+        unset($_SESSION["{$podcastHandle}:subscription"]);
+
+        Events::trigger('lock', $podcastHandle);
+
+        return true;
+    }
+
+    public function isUnlocked(string $podcastHandle): bool
+    {
+        if (array_key_exists(
+            $podcastHandle,
+            $this->subscriptions
+        ) && ($this->subscriptions[$podcastHandle] instanceof Subscription)) {
+            return true;
+        }
+
+        if ($subscription = session()->get("{$podcastHandle}:subscription")) {
+            $this->subscriptions[$podcastHandle] = $subscription;
+
+            return true;
+        }
+
+        return false;
+    }
+
+    public function check(string $podcastHandle): bool
+    {
+        // check if locked, no need to go any further
+        if (! $this->isUnlocked($podcastHandle)) {
+            return false;
+        }
+
+        // Store the current subscription object
+        $this->subscriptions[$podcastHandle] = $this->subscriptionModel->getSubscriptionById(
+            $this->subscriptions[$podcastHandle]->id
+        );
+
+        if (! $this->subscriptions[$podcastHandle] instanceof Subscription) {
+            return false;
+        }
+
+        // lock podcast if subscription is not active
+        if ($this->subscriptions[$podcastHandle]->status !== 'active') {
+            $this->lock($podcastHandle);
+
+            return false;
+        }
+
+        // All good!
+        return true;
+    }
+
+    /**
+     * Returns the Subscription instance for the current logged in user.
+     */
+    public function subscription(string $podcastHandle): ?Subscription
+    {
+        return $this->isUnlocked($podcastHandle) ? $this->subscriptions[$podcastHandle] : null;
+    }
+}
diff --git a/phpstan.neon b/phpstan.neon
index 5f89045a24..cbedb485d8 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -10,6 +10,7 @@ parameters:
         - app/Helpers
         - modules/Analytics/Helpers
         - modules/Fediverse/Helpers
+        - modules/PremiumPodcasts/Helpers
         - vendor/codeigniter4/framework/system/Helpers
         - vendor/myth/auth/src/Helpers
     excludePaths:
diff --git a/public/media/podcasts/index.html b/public/media/podcasts/index.html
deleted file mode 100644
index eebf8ecb2b..0000000000
--- a/public/media/podcasts/index.html
+++ /dev/null
@@ -1,9 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>403 Forbidden</title>
-  </head>
-  <body>
-    <p>Directory access is forbidden.</p>
-  </body>
-</html>
diff --git a/tailwind.config.js b/tailwind.config.js
index 00ffd68a7d..0da9244ac5 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -142,6 +142,9 @@ module.exports = {
           },
         },
       },
+      zIndex: {
+        60: 60,
+      },
     },
   },
   variants: {},
diff --git a/themes/cp_admin/_layout.php b/themes/cp_admin/_layout.php
index 5d25b97012..78e41ca00f 100644
--- a/themes/cp_admin/_layout.php
+++ b/themes/cp_admin/_layout.php
@@ -31,8 +31,15 @@
             <div class="flex flex-col justify-end w-full -mt-4 sticky-header-inner">
                 <?= render_breadcrumb('text-xs items-center flex') ?>
                 <div class="flex justify-between py-1">
-                    <div class="flex flex-wrap items-center overflow-x-hidden">
-                        <Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></Heading>
+                    <div class="flex flex-wrap items-center">
+                    <?php if ((isset($episode) && $episode->is_premium) || (isset($podcast) && $podcast->is_premium)): ?>
+                        <div class="inline-flex items-center">
+                            <IconButton uri="<?= route_to('subscription-list', $podcast->id) ?>" glyph="exchange-dollar" variant="secondary" class="p-0 mr-2 text-4xl border-0"><?= isset($episode) ? lang('PremiumPodcasts.episode_is_premium') : lang('PremiumPodcasts.podcast_is_premium') ?></IconButton>
+                            <Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></Heading>
+                        </div>
+                        <?php else: ?>
+                            <Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></Heading>
+                        <?php endif; ?>
                         <?= $this->renderSection('headerLeft') ?>
                     </div>
                     <div class="flex flex-shrink-0 gap-x-2"><?= $this->renderSection('headerRight') ?></div>
diff --git a/themes/cp_admin/_partials/_nav_aside.php b/themes/cp_admin/_partials/_nav_aside.php
index 2730b01e6f..79ff2a87f6 100644
--- a/themes/cp_admin/_partials/_nav_aside.php
+++ b/themes/cp_admin/_partials/_nav_aside.php
@@ -1,4 +1,4 @@
-<div data-sidebar-toggler="backdrop" role="button" tabIndex="0" aria-label="Close" class="fixed z-50 hidden w-full h-full bg-gray-800/75 md:hidden"></div>
+<div data-sidebar-toggler="backdrop" role="button" tabIndex="0" aria-label="<?= lang('Common.close') ?>" class="fixed z-50 hidden w-full h-full bg-gray-800/75 md:hidden"></div>
 <aside data-sidebar-toggler="sidebar" data-toggle-class="-translate-x-full" data-hide-class="-translate-x-full" class="h-full max-h-[calc(100vh-40px)] sticky z-50 flex flex-col row-start-2 col-start-1 text-white transition duration-200 ease-in-out transform -translate-x-full border-r top-10 border-navigation bg-navigation md:translate-x-0">
     <?php if (isset($podcast) && isset($episode)): ?>
         <?= $this->include('episode/_sidebar') ?>
diff --git a/themes/cp_admin/episode/_card.php b/themes/cp_admin/episode/_card.php
index 0d675fe901..c46b30aece 100644
--- a/themes/cp_admin/episode/_card.php
+++ b/themes/cp_admin/episode/_card.php
@@ -1,10 +1,17 @@
-<article class="relative flex flex-col flex-1 flex-shrink-0 w-full transition group overflow-hidden bg-elevated border-3 snap-center hover:shadow-lg focus-within:shadow-lg focus-within:ring-accent border-subtle rounded-xl min-w-[12rem] max-w-[17rem]">
+<article class="relative flex flex-col flex-1 flex-shrink-0 w-full transition group overflow-hidden bg-elevated border-3 snap-center hover:shadow-lg focus-within:shadow-lg focus-within:ring-accent rounded-xl min-w-[12rem] max-w-[17rem] <?= $episode->is_premium ? 'border-accent-base' : 'border-subtle' ?>">
     <a href="<?= route_to('episode-view', $episode->podcast->id, $episode->id) ?>" class="flex flex-col justify-end w-full h-full text-white group">
         <div class="absolute bottom-0 left-0 z-10 w-full h-full backdrop-gradient mix-blend-multiply"></div>
         <div class="w-full h-full overflow-hidden bg-header">
             <img src="<?= $episode->cover->medium_url ?>" alt="<?= esc($episode->title) ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform group-focus:scale-105 group-hover:scale-105 aspect-square" loading="lazy" />
         </div>
-        <?= publication_pill($episode->published_at, $episode->publication_status, 'absolute top-0 left-0 ml-2 mt-2 text-sm'); ?>
+        <?php if ($episode->is_premium): ?>
+            <div class="absolute top-0 left-0 inline-flex mt-2 gap-x-2">
+                <Icon glyph="exchange-dollar" class="w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg text-accent-contrast bg-accent-base" />
+                <?= publication_pill($episode->published_at, $episode->publication_status, 'text-sm'); ?>
+            </div>
+        <?php else: ?>
+            <?= publication_pill($episode->published_at, $episode->publication_status, 'absolute top-0 left-0 ml-2 mt-2 text-sm'); ?>
+        <?php endif; ?>
         <div class="absolute z-20 flex flex-col items-start px-4 py-2">
             <?= episode_numbering($episode->number, $episode->season_number, 'text-xs font-semibold !no-underline px-1 bg-black/50 mr-1', true) ?>
             <span class="font-semibold leading-tight line-clamp-2"><?= esc($episode->title) ?></span>
diff --git a/themes/cp_admin/episode/_sidebar.php b/themes/cp_admin/episode/_sidebar.php
index 0993601b69..fc3291e847 100644
--- a/themes/cp_admin/episode/_sidebar.php
+++ b/themes/cp_admin/episode/_sidebar.php
@@ -21,7 +21,10 @@ $podcastNavigation = [
     />
     <span class="flex-1 w-full px-2 text-xs font-semibold truncate" title="<?= esc($podcast->title) ?>"><?= esc($podcast->title) ?></span>
 </a>
-<div class="flex items-center px-4 py-2 border-y border-navigation">
+<div class="relative flex items-center px-4 py-2 border-y border-navigation">
+    <?php if ($episode->is_premium): ?>
+        <Icon glyph="exchange-dollar" class="absolute pl-1 text-xl rounded-r-full rounded-tl-lg left-4 top-4 text-accent-contrast bg-accent-base" />
+    <?php endif; ?>
     <img
     src="<?= $episode->cover->thumbnail_url ?>"
     alt="<?= esc($episode->title) ?>"
diff --git a/themes/cp_admin/episode/create.php b/themes/cp_admin/episode/create.php
index a35ab5a884..4953d28b38 100644
--- a/themes/cp_admin/episode/create.php
+++ b/themes/cp_admin/episode/create.php
@@ -107,6 +107,8 @@
     isChecked="false" ><?= lang('Episode.form.parental_advisory.explicit') ?></Forms.RadioButton>
 </fieldset>
 
+
+
 </Forms.Section>
 
 
@@ -131,6 +133,11 @@
 
 </Forms.Section>
 
+<Forms.Section title="<?= lang('Episode.form.premium_title') ?>">
+    <Forms.Toggler class="mt-2" name="premium" value="yes" checked="<?= $podcast->is_premium_by_default ? 'true' : 'false' ?>">
+        <?= lang('Episode.form.premium') ?></Forms.Toggler>
+</Forms.Section>
+
 <Forms.Section
     title="<?= lang('Episode.form.location_section_title') ?>"
     subtitle="<?= lang('Episode.form.location_section_subtitle') ?>"
diff --git a/themes/cp_admin/episode/edit.php b/themes/cp_admin/episode/edit.php
index 64a78eef3a..ab7d6a449c 100644
--- a/themes/cp_admin/episode/edit.php
+++ b/themes/cp_admin/episode/edit.php
@@ -113,7 +113,6 @@
 
 </Forms.Section>
 
-
 <Forms.Section
     title="<?= lang('Episode.form.show_notes_section_title') ?>"
     subtitle="<?= lang('Episode.form.show_notes_section_subtitle') ?>">
@@ -136,6 +135,11 @@
 
 </Forms.Section>
 
+<Forms.Section title="<?= lang('Episode.form.premium_title') ?>" >
+    <Forms.Toggler class="mt-2" name="premium" value="yes" checked="<?= $episode->is_premium ? 'true' : 'false' ?>">
+        <?= lang('Episode.form.premium') ?></Forms.Toggler>
+</Forms.Section>
+
 <Forms.Section
     title="<?= lang('Episode.form.location_section_title') ?>"
     subtitle="<?= lang('Episode.form.location_section_subtitle') ?>"
diff --git a/themes/cp_admin/episode/list.php b/themes/cp_admin/episode/list.php
index da9aad1a95..ff82cdfe71 100644
--- a/themes/cp_admin/episode/list.php
+++ b/themes/cp_admin/episode/list.php
@@ -42,6 +42,11 @@ data_table(
         [
             'header' => lang('Episode.list.episode'),
             'cell' => function ($episode, $podcast) {
+                $premiumBadge = '';
+                if ($episode->is_premium) {
+                    $premiumBadge = '<Icon glyph="exchange-dollar" class="absolute left-0 w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg top-2 text-accent-contrast bg-accent-base" />';
+                }
+
                 return '<div class="flex">' .
                     '<div class="relative flex-shrink-0 mr-2">' .
                         '<time class="absolute px-1 text-xs font-semibold text-white rounded bottom-2 right-2 bg-black/50" datetime="PT' . round($episode->audio->duration, 3) . 'S">' .
@@ -49,6 +54,7 @@ data_table(
                                 (int) $episode->audio->duration,
                             ) .
                         '</time>' .
+                        $premiumBadge .
                         '<img src="' . $episode->cover->thumbnail_url . '" alt="' . esc($episode->title) . '" class="object-cover w-20 rounded-lg shadow-inner aspect-square" loading="lazy" />' .
                     '</div>' .
                     '<a class="overflow-x-hidden text-sm hover:underline" href="' . route_to(
diff --git a/themes/cp_admin/podcast/_card.php b/themes/cp_admin/podcast/_card.php
index 1479777591..3ba7df786f 100644
--- a/themes/cp_admin/podcast/_card.php
+++ b/themes/cp_admin/podcast/_card.php
@@ -1,4 +1,4 @@
-<article class="relative h-full overflow-hidden transition shadow bg-elevated border-3 border-subtle group rounded-xl hover:shadow-xl focus-within:shadow-xl focus-within:ring-accent">
+<article class="relative h-full overflow-hidden transition shadow bg-elevated border-3 group rounded-xl hover:shadow-xl focus-within:shadow-xl focus-within:ring-accent <?= $podcast->is_premium ? 'border-accent-base' : 'border-subtle' ?>">
     <a href="<?= route_to('podcast-view', $podcast->id) ?>" class="flex flex-col justify-end w-full h-full text-white group">
         <div class="absolute bottom-0 left-0 z-10 w-full h-full backdrop-gradient mix-blend-multiply"></div>
         <div class="<?= 'w-full h-full overflow-hidden bg-header' . ($podcast->publication_status !== 'published' ? ' grayscale group-hover:grayscale-[60%]' : '') ?>">
@@ -6,13 +6,27 @@
             alt="<?= esc($podcast->title) ?>"
             src="<?= $podcast->cover->medium_url ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform aspect-square group-focus:scale-105 group-hover:scale-105" loading="lazy" />
         </div>
-        <?php if ($podcast->publication_status !== 'published'): ?>
-            <span class="absolute top-0 left-0 flex items-center px-1 mt-2 ml-2 text-sm font-semibold text-gray-600 border border-gray-600 rounded bg-gray-50">
-                <?= lang('Podcast.draft') ?>
-                <?php if ($podcast->publication_status === 'scheduled'): ?>
-                    <Icon glyph="timer" class="flex-shrink-0 ml-1 text-lg" />
+        <?php if ($podcast->is_premium): ?>
+            <div class="absolute top-0 left-0 inline-flex mt-2 gap-x-2">
+                <Icon glyph="exchange-dollar" class="w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg text-accent-contrast bg-accent-base" />
+                <?php if ($podcast->publication_status !== 'published'): ?>
+                <span class="flex items-center px-1 text-sm font-semibold text-gray-600 border border-gray-600 rounded bg-gray-50">
+                    <?= lang('Podcast.draft') ?>
+                    <?php if ($podcast->publication_status === 'scheduled'): ?>
+                        <Icon glyph="timer" class="flex-shrink-0 ml-1 text-lg" />
+                    <?php endif ?>
+                </span>
                 <?php endif ?>
-            </span>
+            </div>
+        <?php else: ?>
+            <?php if ($podcast->publication_status !== 'published'): ?>
+                <span class="absolute top-0 left-0 flex items-center px-1 mt-2 ml-2 text-sm font-semibold text-gray-600 border border-gray-600 rounded bg-gray-50">
+                    <?= lang('Podcast.draft') ?>
+                    <?php if ($podcast->publication_status === 'scheduled'): ?>
+                        <Icon glyph="timer" class="flex-shrink-0 ml-1 text-lg" />
+                    <?php endif ?>
+                </span>
+            <?php endif ?>
         <?php endif ?>
         <div class="absolute z-20 w-full px-4 pb-4 transition duration-75 ease-out translate-y-6 group-focus:translate-y-0 group-hover:translate-y-0">
             <h2 class="font-bold leading-none truncate font-display"><?= esc($podcast->title) ?></h2>
diff --git a/themes/cp_admin/podcast/_sidebar.php b/themes/cp_admin/podcast/_sidebar.php
index bbe873e889..360d531cfa 100644
--- a/themes/cp_admin/podcast/_sidebar.php
+++ b/themes/cp_admin/podcast/_sidebar.php
@@ -9,6 +9,10 @@ $podcastNavigation = [
         'icon' => 'play-circle',
         'items' => ['episode-list', 'episode-create'],
     ],
+    'premium' => [
+        'icon' => 'exchange-dollar',
+        'items' => ['subscription-list', 'subscription-add'],
+    ],
     'analytics' => [
         'icon' => 'line-chart',
         'items' => [
@@ -41,7 +45,10 @@ $counts = [
 
 ?>
 
-<div class="flex items-center px-4 py-2 border-b border-navigation">
+<div class="relative flex items-center px-4 py-2 border-b border-navigation">
+    <?php if ($podcast->is_premium): ?>
+        <Icon glyph="exchange-dollar" class="absolute pl-1 text-xl rounded-r-full rounded-tl-lg left-4 top-4 text-accent-contrast bg-accent-base" />
+    <?php endif; ?>
     <img
     src="<?= $podcast->cover->thumbnail_url ?>"
     alt="<?= esc($podcast->title) ?>"
diff --git a/themes/cp_admin/podcast/create.php b/themes/cp_admin/podcast/create.php
index 6cfc85b0e8..9906da3cb0 100644
--- a/themes/cp_admin/podcast/create.php
+++ b/themes/cp_admin/podcast/create.php
@@ -148,6 +148,11 @@
 
 </Forms.Section>
 
+<Forms.Section title="<?= lang('Podcast.form.premium') ?>">
+    <Forms.Toggler class="mt-2" name="premium_by_default" value="yes" checked="false" hint="<?= lang('Podcast.form.premium_by_default_hint') ?>">
+        <?= lang('Podcast.form.premium_by_default') ?></Forms.Toggler>
+</Forms.Section>
+
 <Forms.Section
     title="<?= lang('Podcast.form.location_section_title') ?>"
     subtitle="<?= lang('Podcast.form.location_section_subtitle') ?>" >
diff --git a/themes/cp_admin/podcast/edit.php b/themes/cp_admin/podcast/edit.php
index 7e90356892..6f01f9d8cf 100644
--- a/themes/cp_admin/podcast/edit.php
+++ b/themes/cp_admin/podcast/edit.php
@@ -169,6 +169,11 @@
 
 </Forms.Section>
 
+<Forms.Section title="<?= lang('Podcast.form.premium') ?>">
+    <Forms.Toggler class="mt-2" name="premium_by_default" value="yes" checked="<?= $podcast->is_premium_by_default ? 'true' : 'false' ?>" hint="<?= lang('Podcast.form.premium_by_default_hint') ?>">
+        <?= lang('Podcast.form.premium_by_default') ?></Forms.Toggler>
+</Forms.Section>
+
 <Forms.Section
     title="<?= lang('Podcast.form.location_section_title') ?>"
     subtitle="<?= lang('Podcast.form.location_section_subtitle') ?>" >
diff --git a/themes/cp_admin/settings/general.php b/themes/cp_admin/settings/general.php
index 1bafc54608..4a17d2efdd 100644
--- a/themes/cp_admin/settings/general.php
+++ b/themes/cp_admin/settings/general.php
@@ -77,9 +77,10 @@
     title="<?= lang('Settings.housekeeping.title') ?>"
     subtitle="<?= lang('Settings.housekeeping.subtitle') ?>" >
 
-    <Forms.Toggler name="reset_counts" value="yes" size="small" checked="true" hint="<?= lang('Settings.housekeeping.reset_counts_helper') ?>"><?= lang('Settings.housekeeping.reset_counts') ?></Forms.Toggler>
-    <Forms.Toggler name="rewrite_media" value="yes" size="small" checked="true" hint="<?= lang('Settings.housekeeping.rewrite_media_helper') ?>"><?= lang('Settings.housekeeping.rewrite_media') ?></Forms.Toggler>
-    <Forms.Toggler name="clear_cache" value="yes" size="small" checked="true" hint="<?= lang('Settings.housekeeping.clear_cache_helper') ?>"><?= lang('Settings.housekeeping.clear_cache') ?></Forms.Toggler>
+    <Forms.Toggler name="reset_counts" value="yes" size="small" checked="false" hint="<?= lang('Settings.housekeeping.reset_counts_helper') ?>"><?= lang('Settings.housekeeping.reset_counts') ?></Forms.Toggler>
+    <Forms.Toggler name="rewrite_media" value="yes" size="small" checked="false" hint="<?= lang('Settings.housekeeping.rewrite_media_helper') ?>"><?= lang('Settings.housekeeping.rewrite_media') ?></Forms.Toggler>
+    <Forms.Toggler name="rename_episodes_files" value="yes" size="small" checked="false" hint="<?= lang('Settings.housekeeping.rename_episodes_files_hint') ?>"><?= lang('Settings.housekeeping.rename_episodes_files') ?></Forms.Toggler>
+    <Forms.Toggler name="clear_cache" value="yes" size="small" checked="false" hint="<?= lang('Settings.housekeeping.clear_cache_helper') ?>"><?= lang('Settings.housekeeping.clear_cache') ?></Forms.Toggler>
 
     <Button variant="primary" type="submit" iconLeft="home-gear"><?= lang('Settings.housekeeping.run') ?></Button>
 
diff --git a/themes/cp_admin/subscription/add.php b/themes/cp_admin/subscription/add.php
new file mode 100644
index 0000000000..0cd956bf83
--- /dev/null
+++ b/themes/cp_admin/subscription/add.php
@@ -0,0 +1,35 @@
+<?= $this->extend('../cp_admin/_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('Subscription.add', [esc($podcast->title)]) ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('pageTitle') ?>
+<?= lang('Subscription.add', [esc($podcast->title)]) ?>
+<?= $this->endSection() ?>
+
+
+<?= $this->section('content') ?>
+
+<form method="POST" action="<?= route_to('subscription-add', $podcast->id) ?>" class="flex flex-col max-w-sm gap-y-4">
+<?= csrf_field() ?>
+<input type="hidden" name="client_timezone" value="UTC" />
+
+<Forms.Field
+    name="email"
+    type="email"
+    label="<?= lang('Subscription.form.email') ?>"
+    required="true" />
+
+<Forms.Field
+    as="DatetimePicker"
+    name="expiration_date"
+    label="<?= lang('Subscription.form.expiration_date') ?>"
+    hint="<?= lang('Subscription.form.expiration_date_hint') ?>"
+/>
+
+<Button type="submit" class="self-end" variant="primary"><?= lang('Subscription.form.submit_add') ?></Button>
+
+</form>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_admin/subscription/delete.php b/themes/cp_admin/subscription/delete.php
new file mode 100644
index 0000000000..24248509ec
--- /dev/null
+++ b/themes/cp_admin/subscription/delete.php
@@ -0,0 +1,29 @@
+<?= $this->extend('_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('Subscription.delete') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('pageTitle') ?>
+<?= lang('Subscription.delete') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<form action="<?= route_to('subscription-delete', $podcast->id, $subscription->id) ?>" method="POST" class="flex flex-col w-full max-w-xl mx-auto">
+<?= csrf_field() ?>
+
+<Alert variant="danger" glyph="alert" class="font-semibold"><?= lang('Subscription.delete_form.disclaimer', [
+    'subscriber' => $subscription->email,
+]) ?></Alert>
+
+<Forms.Checkbox class="mt-2" name="understand" required="true" isChecked="false"><?= lang('Subscription.delete_form.understand') ?></Forms.Checkbox>
+
+<div class="flex items-center self-end mt-4 gap-x-2">
+    <Button uri="<?= route_to('subscription-list', $podcast->id) ?>"><?= lang('Common.cancel') ?></Button>
+    <Button type="submit" variant="danger"><?= lang('Subscription.delete_form.submit') ?></Button>
+</div>
+
+</form>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_admin/subscription/edit.php b/themes/cp_admin/subscription/edit.php
new file mode 100644
index 0000000000..761ebc2c0c
--- /dev/null
+++ b/themes/cp_admin/subscription/edit.php
@@ -0,0 +1,39 @@
+<?= $this->extend('../cp_admin/_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('Subscription.edit', [esc($podcast->title)]) ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('pageTitle') ?>
+<?= lang('Subscription.edit', [esc($podcast->title)]) ?>
+<?= $this->endSection() ?>
+
+
+<?= $this->section('content') ?>
+
+<form method="POST" action="<?= route_to('subscription-edit', $podcast->id, $subscription->id) ?>" class="flex flex-col max-w-sm gap-y-4">
+<?= csrf_field() ?>
+<input type="hidden" name="client_timezone" value="UTC" />
+
+<div class="px-4 py-5 bg-base sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
+    <dt class="text-sm font-medium leading-5 text-skin-muted">
+    <?= lang('Subscription.list.email') ?>
+    </dt>
+    <dd class="mt-1 text-sm leading-5 sm:mt-0 sm:col-span-2">
+    <?= esc($subscription->email) ?>
+    </dd>
+</div>
+
+<Forms.Field
+    as="DatetimePicker"
+    name="expiration_date"
+    label="<?= lang('Subscription.form.expiration_date') ?>"
+    hint="<?= lang('Subscription.form.expiration_date_hint') ?>"
+    value="<?= $subscription->expires_at ?>"
+/>
+
+<Button type="submit" class="self-end" variant="primary"><?= lang('Subscription.form.submit_edit') ?></Button>
+
+</form>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_admin/subscription/email/_credentials_list.php b/themes/cp_admin/subscription/email/_credentials_list.php
new file mode 100644
index 0000000000..1e7460c3f4
--- /dev/null
+++ b/themes/cp_admin/subscription/email/_credentials_list.php
@@ -0,0 +1,4 @@
+<ul>
+    <li><?= lang('Subscription.emails.token', ['<strong>' . $token . '</strong>'], $subscription->podcast->language_code, false) ?> </li>
+    <li><?= lang('Subscription.emails.unique_feed_link', ['<a href="' . $subscription->podcast->feedUrl . '?token=' . $token . '">' . $subscription->podcast->feedUrl . '?token=' . $token . '</a>'], $subscription->podcast->language_code, false) ?> </li>
+</ul>
diff --git a/themes/cp_admin/subscription/email/_footer.php b/themes/cp_admin/subscription/email/_footer.php
new file mode 100644
index 0000000000..f97a7c96b3
--- /dev/null
+++ b/themes/cp_admin/subscription/email/_footer.php
@@ -0,0 +1,6 @@
+<br /><br />---<br />
+
+<small><?= lang('Subscription.emails.footer', [
+    'castopod' => '<a href="https://castopod.org/">Castopod</a>',
+    'host' => '<a href="' . base_url('', 'https') . '">' . current_domain() . '</a>',
+], $subscription->podcast->language_code, false) ?></small>
diff --git a/themes/cp_admin/subscription/email/_how_to_use.php b/themes/cp_admin/subscription/email/_how_to_use.php
new file mode 100644
index 0000000000..04ddb480ac
--- /dev/null
+++ b/themes/cp_admin/subscription/email/_how_to_use.php
@@ -0,0 +1,8 @@
+<h3><?= lang('Subscription.emails.how_to_use', [], $subscription->podcast->language_code) ?></h3>
+<p><?= lang('Subscription.emails.two_ways', [], $subscription->podcast->language_code) ?></p>
+<ol>
+    <li><?= lang('Subscription.emails.import_into_app', [], $subscription->podcast->language_code) ?></li>
+    <li><?= lang('Subscription.emails.go_to_website', [
+        'podcastWebsite' => '<a href="' . url_to('podcast-episodes', esc($subscription->podcast->handle)) . '">' . $subscription->podcast->title . '</a>',
+    ], $subscription->podcast->language_code, false) ?></li>
+</ol>
diff --git a/themes/cp_admin/subscription/email/edited.php b/themes/cp_admin/subscription/email/edited.php
new file mode 100644
index 0000000000..3b21d9d39a
--- /dev/null
+++ b/themes/cp_admin/subscription/email/edited.php
@@ -0,0 +1,18 @@
+<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/>
+
+<?php if ($subscription->expires_at): ?>
+    <?php
+        $formatter = new IntlDateFormatter($subscription->podcast->language_code, IntlDateFormatter::LONG, IntlDateFormatter::LONG);
+        $translatedDate = $subscription->expires_at->toLocalizedString($formatter->getPattern());
+    ?>
+    <?= lang('Subscription.emails.edited_expires', [
+        'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
+        'expiresAt' => '<strong>' . $translatedDate . '</strong>',
+    ], $subscription->podcast->language_code, false) ?>
+<?php else: ?>
+    <?= lang('Subscription.emails.edited_never_expires', [
+        'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
+    ], $subscription->podcast->language_code, false) ?>
+<?php endif; ?>
+
+<?= $this->include('subscription/email/_footer') ?>
diff --git a/themes/cp_admin/subscription/email/removed.php b/themes/cp_admin/subscription/email/removed.php
new file mode 100644
index 0000000000..909067dce6
--- /dev/null
+++ b/themes/cp_admin/subscription/email/removed.php
@@ -0,0 +1,7 @@
+<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/>
+
+<?= lang('Subscription.emails.removed', [
+    'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
+], $subscription->podcast->language_code, false) ?>
+
+<?= $this->include('subscription/email/_footer') ?>
diff --git a/themes/cp_admin/subscription/email/reset.php b/themes/cp_admin/subscription/email/reset.php
new file mode 100644
index 0000000000..dea6449f79
--- /dev/null
+++ b/themes/cp_admin/subscription/email/reset.php
@@ -0,0 +1,13 @@
+<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/>
+
+<?= lang('Subscription.emails.reset_token', [
+    'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
+], $subscription->podcast->language_code, false) ?><br/><br/>
+
+<?= lang('Subscription.emails.reset_token_title', [], $subscription->podcast->language_code) ?>
+
+<?= $this->include('subscription/email/_credentials_list') ?>
+
+<?= $this->include('subscription/email/_how_to_use') ?>
+
+<?= $this->include('subscription/email/_footer') ?>
diff --git a/themes/cp_admin/subscription/email/resumed.php b/themes/cp_admin/subscription/email/resumed.php
new file mode 100644
index 0000000000..f06f08a46a
--- /dev/null
+++ b/themes/cp_admin/subscription/email/resumed.php
@@ -0,0 +1,7 @@
+<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/>
+
+<?= lang('Subscription.emails.resumed', [
+    'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
+], $subscription->podcast->language_code, false) ?>
+
+<?= $this->include('subscription/email/_footer') ?>
diff --git a/themes/cp_admin/subscription/email/suspended.php b/themes/cp_admin/subscription/email/suspended.php
new file mode 100644
index 0000000000..e59a34e032
--- /dev/null
+++ b/themes/cp_admin/subscription/email/suspended.php
@@ -0,0 +1,11 @@
+<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/>
+
+<?= lang('Subscription.emails.suspended', [
+    'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
+], $subscription->podcast->language_code, false) ?><br/><br/>
+
+<?php if ($subscription->status_message): ?>
+    <?= lang('Subscription.emails.suspended_reason', ['<br /><br /><code>' . nl2br($subscription->status_message) . '</code>'], $subscription->podcast->language_code, false) ?>
+<?php endif; ?>
+
+<?= $this->include('subscription/email/_footer') ?>
diff --git a/themes/cp_admin/subscription/email/welcome.php b/themes/cp_admin/subscription/email/welcome.php
new file mode 100644
index 0000000000..8fa9c84802
--- /dev/null
+++ b/themes/cp_admin/subscription/email/welcome.php
@@ -0,0 +1,23 @@
+<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/>
+
+<?= lang('Subscription.emails.welcome', [
+    'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>',
+], $subscription->podcast->language_code, false) ?><br/><br/>
+
+<?= lang('Subscription.emails.welcome_token_title', [], $subscription->podcast->language_code) ?>
+
+<?= $this->include('subscription/email/_credentials_list') ?>
+
+<?php if ($subscription->expires_at): ?>
+    <?php
+        $formatter = new IntlDateFormatter($subscription->podcast->language_code, IntlDateFormatter::LONG, IntlDateFormatter::LONG);
+        $translatedDate = $subscription->expires_at->toLocalizedString($formatter->getPattern());
+    ?>
+    <?= lang('Subscription.emails.welcome_expires', ['<strong>' . $translatedDate . '</strong>'], $subscription->podcast->language_code, false) ?>
+<?php else: ?>
+    <?= lang('Subscription.emails.welcome_never_expires', [], $subscription->podcast->language_code) ?>
+<?php endif; ?>
+
+<?= $this->include('subscription/email/_how_to_use') ?>
+
+<?= $this->include('subscription/email/_footer') ?>
diff --git a/themes/cp_admin/subscription/list.php b/themes/cp_admin/subscription/list.php
new file mode 100644
index 0000000000..3d09685eed
--- /dev/null
+++ b/themes/cp_admin/subscription/list.php
@@ -0,0 +1,130 @@
+<?= $this->extend('../cp_admin/_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('Subscription.podcast_subscriptions') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('pageTitle') ?>
+<?= lang('Subscription.podcast_subscriptions') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('headerRight') ?>
+<Button uri="<?= route_to('subscription-add', $podcast->id) ?>" variant="primary" iconLeft="add"><?= lang('Subscription.add') ?></Button>
+<?= $this->endSection() ?>
+
+
+<?= $this->section('content') ?>
+
+<form method="POST" action="<?= route_to('subscription-link-save', $podcast->id) ?>" class="flex flex-col items-start max-w-sm gap-y-1">
+    <?= csrf_field() ?>
+    <Forms.Field
+        class="w-full"
+        type="url"
+        name="subscription_link"
+        label="<?= lang('Subscription.form_link_add.link') ?>"
+        hint="<?= lang('Subscription.form_link_add.link_hint') ?>"
+        placeholder="https://…"
+        value="<?= service('settings')
+        ->get('Subscription.link', 'podcast:' . $podcast->id) ?>" />
+    <Button variant="primary" type="submit"><?= lang('Subscription.form_link_add.submit') ?></Button>
+</form>
+
+<hr class="my-6 border-subtle">
+
+<?= data_table(
+            [
+                [
+                    'header' => lang('Subscription.list.number'),
+                    'cell' => function ($subscription) {
+                        return '#' . $subscription->id;
+                    },
+                ],
+                [
+                    'header' => lang('Subscription.list.email'),
+                    'cell' => function ($subscription) {
+                        return esc($subscription->email);
+                    },
+                ],
+                [
+                    'header' => lang('Subscription.list.expiration_date'),
+                    'cell' => function ($subscription) {
+                        return $subscription->expires_at ? local_date($subscription->expires_at) : lang('Subscription.list.unlimited');
+                    },
+                ],
+                [
+                    'header' => lang('Subscription.list.downloads'),
+                    'cell' => function ($subscription) {
+                        return $subscription->downloads_last_3_months;
+                    },
+                ],
+                [
+                    'header' => lang('Subscription.list.status'),
+                    'cell' => function ($subscription) {
+                        $statusMapping = [
+                            'active' => 'success',
+                            'suspended' => 'warning',
+                            'expired' => 'default',
+                        ];
+
+                        return '<Pill variant="' . $statusMapping[$subscription->status] . '" class="lowercase">' . lang('Subscription.status.' . $subscription->status) . '</Pill>';
+                    },
+                ],
+                [
+                    'header' => lang('Common.actions'),
+                    'cell' => function ($subscription, $podcast) {
+                        $items = [
+                            [
+                                'type' => 'link',
+                                'title' => lang('Subscription.view'),
+                                'uri' => route_to('subscription-view', $podcast->id, $subscription->id),
+                            ],
+                            [
+                                'type' => 'link',
+                                'title' => lang('Subscription.edit'),
+                                'uri' => route_to('subscription-edit', $podcast->id, $subscription->id),
+                            ],
+                            [
+                                'type' => 'link',
+                                'title' => lang('Subscription.regenerate_token'),
+                                'uri' => route_to('subscription-regenerate-token', $podcast->id, $subscription->id),
+                            ],
+                            [
+                                'type' => 'separator',
+                            ],
+                            [
+                                'type' => 'link',
+                                'title' => lang('Subscription.delete'),
+                                'uri' => route_to('subscription-remove', $podcast->id, $subscription->id),
+                                'class' => 'font-semibold text-red-600',
+                            ],
+                        ];
+
+                        if ($subscription->status === 'suspended') {
+                            $suspendAction = [[
+                                'type' => 'link',
+                                'title' => lang('Subscription.resume'),
+                                'uri' => route_to('subscription-resume', $podcast->id, $subscription->id),
+                            ]];
+                        } else {
+                            $suspendAction = [[
+                                'type' => 'link',
+                                'title' => lang('Subscription.suspend'),
+                                'uri' => route_to('subscription-suspend', $podcast->id, $subscription->id),
+                            ]];
+                        }
+
+                        array_splice($items, 3, 0, $suspendAction);
+
+                        return '<button id="more-dropdown-' . $subscription->id . '" type="button" class="inline-flex items-center p-1 rounded-full focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $subscription->id . '-menu" aria-haspopup="true" aria-expanded="false">' .
+                            icon('more') .
+                            '</button>' .
+                            '<DropdownMenu id="more-dropdown-' . $subscription->id . '-menu" labelledby="more-dropdown-' . $subscription->id . '" offsetY="-24" items="' . esc(json_encode($items)) . '" />';
+                    },
+                ],
+            ],
+            $podcast->subscriptions,
+            '',
+            $podcast,
+        ) ?>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_admin/subscription/suspend.php b/themes/cp_admin/subscription/suspend.php
new file mode 100644
index 0000000000..046ad2af32
--- /dev/null
+++ b/themes/cp_admin/subscription/suspend.php
@@ -0,0 +1,36 @@
+<?= $this->extend('_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('Subscription.suspend') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('pageTitle') ?>
+<?= lang('Subscription.suspend') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<form action="<?= route_to('subscription-suspend', $podcast->id, $subscription->id) ?>" method="POST" class="flex flex-col w-full max-w-xl mx-auto">
+<?= csrf_field() ?>
+
+<Alert variant="warning" glyph="alert" class="font-semibold"><?= lang('Subscription.suspend_form.disclaimer', [
+    'email' => $subscription->email,
+]) ?></Alert>
+
+<Forms.Field
+    as="Textarea"
+    name="reason"
+    label="<?= lang('Subscription.suspend_form.reason') ?>"
+    placeholder="<?= lang('Subscription.suspend_form.reason_placeholder') ?>"
+    rows="4"
+    class="mt-4"
+/>
+
+<div class="flex items-center self-end mt-4 gap-x-2">
+    <Button uri="<?= route_to('subscription-list', $podcast->id) ?>"><?= lang('Common.cancel') ?></Button>
+    <Button type="submit" variant="warning" iconLeft="pause"><?= lang('Subscription.suspend_form.submit') ?></Button>
+</div>
+
+</form>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_admin/subscription/view.php b/themes/cp_admin/subscription/view.php
new file mode 100644
index 0000000000..b17b6acf87
--- /dev/null
+++ b/themes/cp_admin/subscription/view.php
@@ -0,0 +1,19 @@
+<?= $this->extend('../cp_admin/_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('Subscription.view', [
+    esc($subscription->id),
+]) ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('pageTitle') ?>
+<?= lang('Subscription.view', [
+    esc($subscription->id),
+]) ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<?= $subscription->email ?>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_app/episode/_layout.php b/themes/cp_app/episode/_layout.php
index 075a28ad4f..48e1a0edbe 100644
--- a/themes/cp_app/episode/_layout.php
+++ b/themes/cp_app/episode/_layout.php
@@ -83,6 +83,9 @@
         <div class="z-10 flex flex-col items-start gap-y-2 gap-x-4 sm:flex-row">
             <div class="relative flex-shrink-0">
                 <?= explicit_badge($episode->parental_advisory === 'explicit', 'rounded absolute left-0 bottom-0 ml-2 mb-2 bg-black/75 text-accent-contrast') ?>
+                <?php if ($episode->is_premium): ?>
+                    <Icon glyph="exchange-dollar" class="absolute left-0 w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg top-2 text-accent-contrast bg-accent-base" />
+                <?php endif; ?>
                 <img src="<?= $episode->cover->medium_url ?>" alt="<?= esc($episode->title) ?>" class="flex-shrink-0 rounded-md shadow-xl h-36 aspect-square" loading="lazy" />
             </div>
             <div class="flex flex-col items-start w-full min-w-0 text-white">
@@ -139,11 +142,12 @@
         <?php endif; ?>
     </div>
     <?= $this->include('episode/_partials/navigation') ?>
+    <?= $this->include('podcast/_partials/premium_banner') ?>
     <div class="relative grid items-start flex-1 col-start-2 grid-cols-podcastMain gap-x-6">
         <main class="w-full col-start-1 row-start-1 py-6 col-span-full md:col-span-1">
             <?= $this->renderSection('content') ?>
         </main>
-        <div data-sidebar-toggler="backdrop" class="absolute top-0 left-0 z-10 hidden w-full h-full bg-backdrop/75 md:hidden" role="button" tabIndex="0" aria-label="Close"></div>
+        <div data-sidebar-toggler="backdrop" class="absolute top-0 left-0 z-10 hidden w-full h-full bg-backdrop/75 md:hidden" role="button" tabIndex="0" aria-label="<?= lang('Common.close') ?>"></div>
         <?= $this->include('podcast/_partials/sidebar') ?>
     </div>
     <?= view('_persons_modal', [
diff --git a/themes/cp_app/episode/_partials/card.php b/themes/cp_app/episode/_partials/card.php
index 7bfd5e7c28..165b00c0f9 100644
--- a/themes/cp_app/episode/_partials/card.php
+++ b/themes/cp_app/episode/_partials/card.php
@@ -1,27 +1,36 @@
-<article class="flex w-full p-4 shadow bg-elevated rounded-conditional-2xl gap-x-2">
-    <div class="relative">
+<article class="flex w-full p-3 shadow border-3 bg-elevated rounded-conditional-2xl gap-x-2 <?= $episode->is_premium ? 'border-accent-base' : 'border-transparent' ?>">
+    <div class="relative flex-shrink-0 w-20">
         <time class="absolute px-1 text-xs font-semibold text-white rounded bottom-2 right-2 bg-black/75" datetime="PT<?= round($episode->audio->duration, 3) ?>S">
             <?= format_duration((int) $episode->audio->duration) ?>
         </time>
+        <?php if ($episode->is_premium): ?>
+            <Icon glyph="exchange-dollar" class="absolute left-0 w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg top-2 text-accent-contrast bg-accent-base" />
+        <?php endif; ?>
         <img src="<?= $episode->cover
-                ->thumbnail_url ?>" alt="<?= esc($episode->title) ?>" class="object-cover w-20 rounded-lg shadow-inner aspect-square" loading="lazy" />
+            ->thumbnail_url ?>" alt="<?= esc($episode->title) ?>" class="object-cover w-full rounded-lg shadow-inner aspect-square" loading="lazy" />
     </div>
     <div class="flex items-center flex-1 gap-x-4">
         <div class="flex flex-col flex-1">
-            <div class="inline-flex items-center">
+            <div class="flex flex-wrap items-center">
                 <?= episode_numbering($episode->number, $episode->season_number, 'text-xs font-semibold border-subtle text-skin-muted px-1 border mr-2 !no-underline', true) ?>
                 <?= relative_time($episode->published_at, 'text-xs whitespace-nowrap text-skin-muted') ?>
             </div>
             <h2 class="flex-1 mt-1 font-semibold leading-tight line-clamp-2"><a class="hover:underline" href="<?= $episode->link ?>"><?= esc($episode->title) ?></a></h2>
         </div>
-        <play-episode-button
-            id="<?= $episode->id ?>"
-            imageSrc="<?= $episode->cover->thumbnail_url ?>"
-            title="<?= esc($episode->title) ?>"
-            podcast="<?= esc($episode->podcast->title) ?>"
-            src="<?= $episode->audio_web_url ?>"
-            mediaType="<?= $episode->audio->file_mimetype ?>"
-            playLabel="<?= lang('Common.play_episode_button.play') ?>"
-            playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button>
+        <?php if ($episode->is_premium && ! subscription($podcast->handle)): ?>
+            <a href="<?= route_to('episode', $episode->podcast->handle, $episode->slug) ?>" class="p-3 rounded-full bg-brand bg-accent-base text-accent-contrast hover:bg-accent-hover focus:ring-accent" title="<?= lang('PremiumPodcasts.unlock_episode') ?>" data-tooltip="bottom">
+                <Icon glyph="lock" class="text-xl" />
+            </a>
+        <?php else: ?>
+            <play-episode-button
+                id="<?= $episode->id ?>"
+                imageSrc="<?= $episode->cover->thumbnail_url ?>"
+                title="<?= esc($episode->title) ?>"
+                podcast="<?= esc($episode->podcast->title) ?>"
+                src="<?= $episode->audio_web_url ?>"
+                mediaType="<?= $episode->audio->file_mimetype ?>"
+                playLabel="<?= lang('Common.play_episode_button.play') ?>"
+                playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button>
+        <?php endif; ?>
     </div>
 </article>
diff --git a/themes/cp_app/episode/_partials/preview_card.php b/themes/cp_app/episode/_partials/preview_card.php
index 7bcd153029..0d425b5663 100644
--- a/themes/cp_app/episode/_partials/preview_card.php
+++ b/themes/cp_app/episode/_partials/preview_card.php
@@ -3,9 +3,12 @@
         <time class="absolute px-1 text-sm font-semibold text-white rounded bg-black/75 bottom-2 right-2" datetime="PT<?= round($episode->audio->duration, 3) ?>S">
             <?= format_duration((int) $episode->audio->duration) ?>
         </time>
+        <?php if ($episode->is_premium): ?>
+            <Icon glyph="exchange-dollar" class="absolute left-0 w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg top-2 text-accent-contrast bg-accent-base" />
+        <?php endif; ?>
         <img
-        src="<?= $episode->cover->thumbnail_url ?>"
-        alt="<?= esc($episode->title) ?>" class="w-24 h-24 aspect-square" loading="lazy" />
+            src="<?= $episode->cover->thumbnail_url ?>"
+            alt="<?= esc($episode->title) ?>" class="w-24 h-24 aspect-square" loading="lazy" />
     </div>
     <div class="flex flex-col flex-1 px-4 py-2">
         <div class="inline-flex">
@@ -14,14 +17,20 @@
         </div>
         <a href="<?= $episode->link ?>" class="flex items-baseline font-semibold line-clamp-2" title="<?= esc($episode->title) ?>"><?= esc($episode->title) ?></a>
     </div>
-    <play-episode-button
-        class="mr-4"
-        id="<?= $index . '_' . $episode->id ?>"
-        imageSrc="<?= $episode->cover->thumbnail_url ?>"
-        title="<?= esc($episode->title) ?>"
-        podcast="<?= esc($episode->podcast->title) ?>"
-        src="<?= $episode->audio_web_url ?>"
-        mediaType="<?= $episode->audio->file_mimetype ?>"
-        playLabel="<?= lang('Common.play_episode_button.play') ?>"
-        playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button>
+    <?php if ($episode->is_premium && ! subscription($episode->podcast->handle)): ?>
+        <a href="<?= route_to('episode', $episode->podcast->handle, $episode->slug) ?>" class="p-3 mr-4 rounded-full bg-brand bg-accent-base text-accent-contrast hover:bg-accent-hover focus:ring-accent" title="<?= lang('PremiumPodcasts.unlock_episode') ?>" data-tooltip="bottom">
+            <Icon glyph="lock" class="text-xl" />
+        </a>
+    <?php else: ?>
+        <play-episode-button
+            class="mr-4"
+            id="<?= $index . '_' . $episode->id ?>"
+            imageSrc="<?= $episode->cover->thumbnail_url ?>"
+            title="<?= esc($episode->title) ?>"
+            podcast="<?= esc($episode->podcast->title) ?>"
+            src="<?= $episode->audio_web_url ?>"
+            mediaType="<?= $episode->audio->file_mimetype ?>"
+            playLabel="<?= lang('Common.play_episode_button.play') ?>"
+            playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button>
+    <?php endif; ?>
 </div>
\ No newline at end of file
diff --git a/themes/cp_app/home.php b/themes/cp_app/home.php
index 13fed8fefd..c6f62d4cff 100644
--- a/themes/cp_app/home.php
+++ b/themes/cp_app/home.php
@@ -76,11 +76,18 @@
         <div class="grid gap-4 mt-4 grid-cols-cards">
             <?php if ($podcasts): ?>
                 <?php foreach ($podcasts as $podcast): ?>
-                    <a href="<?= $podcast->link ?>" class="relative w-full h-full overflow-hidden transition shadow focus:ring-accent rounded-xl border-subtle hover:shadow-xl focus:shadow-xl group border-3">
+                    <a href="<?= $podcast->link ?>" class="relative w-full h-full overflow-hidden transition shadow focus:ring-accent rounded-xl hover:shadow-xl focus:shadow-xl group border-3 <?= $podcast->is_premium ? 'border-accent-base' : 'border-subtle' ?>">
                         <article class="text-white">
                             <div class="absolute bottom-0 left-0 z-10 w-full h-full backdrop-gradient mix-blend-multiply"></div>
                             <div class="w-full h-full overflow-hidden bg-header">
-                                <?= explicit_badge($podcast->parental_advisory === 'explicit', 'absolute top-0 left-0 z-10 rounded bg-black/75 ml-2 mt-2') ?>
+                                <?php if ($podcast->is_premium): ?>
+                                    <div class="absolute top-0 left-0 z-10 inline-flex items-center mt-2 gap-x-2">
+                                        <Icon glyph="exchange-dollar" class="w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg text-accent-contrast bg-accent-base" />
+                                        <?= explicit_badge($podcast->parental_advisory === 'explicit', 'rounded bg-black/75') ?>
+                                    </div>
+                                <?php else: ?>
+                                    <?= explicit_badge($podcast->parental_advisory === 'explicit', 'absolute top-0 left-0 z-10 rounded bg-black/75 ml-2 mt-2') ?>
+                                <?php endif; ?>
                                 <img alt="<?= esc($podcast->title) ?>" src="<?= $podcast->cover->medium_url ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform bg-header aspect-square group-focus:scale-105 group-hover:scale-105" loading="lazy" />
                             </div>
                             <div class="absolute bottom-0 left-0 z-20 w-full px-4 pb-2">
diff --git a/themes/cp_app/podcast/_layout.php b/themes/cp_app/podcast/_layout.php
index 3b7900869f..2136465aa8 100644
--- a/themes/cp_app/podcast/_layout.php
+++ b/themes/cp_app/podcast/_layout.php
@@ -43,7 +43,7 @@
         </div>
     <?php endif; ?>
 
-    <header class="relative z-50 flex flex-col-reverse justify-between w-full col-start-2 bg-top bg-no-repeat bg-cover sm:flex-row sm:items-end bg-header aspect-[3/1]" style="background-image: url('<?= $podcast->banner->medium_url ?>');">
+    <header class="min-h-[200px] relative z-50 flex flex-col-reverse justify-between w-full gap-x-2 col-start-2 bg-top bg-no-repeat bg-cover sm:flex-row sm:items-end bg-header aspect-[3/1]" style="background-image: url('<?= $podcast->banner->medium_url ?>');">
         <div class="absolute bottom-0 left-0 w-full h-full backdrop-gradient mix-blend-multiply"></div>
         <div class="z-10 flex items-center pl-4 -mb-6 md:pl-8 md:-mb-8 gap-x-4">
             <img src="<?= $podcast->cover->thumbnail_url ?>" alt="<?= esc($podcast->title) ?>" class="h-24 rounded-full sm:h-28 md:h-36 ring-3 ring-background-elevated aspect-square" loading="lazy" />
@@ -77,6 +77,7 @@
         </div>
     </header>
     <?= $this->include('podcast/_partials/navigation') ?>
+    <?= $this->include('podcast/_partials/premium_banner') ?>
     <div class="relative grid items-start flex-1 col-start-2 grid-cols-podcastMain gap-x-6">
         <main class="w-full max-w-xl col-start-1 row-start-1 py-6 mx-auto col-span-full md:col-span-1">
             <?= $this->renderSection('content') ?>
diff --git a/themes/cp_app/podcast/_partials/premium_banner.php b/themes/cp_app/podcast/_partials/premium_banner.php
new file mode 100644
index 0000000000..69b6e636af
--- /dev/null
+++ b/themes/cp_app/podcast/_partials/premium_banner.php
@@ -0,0 +1,46 @@
+<?php declare(strict_types=1);
+
+if ($podcast->is_premium): ?>
+    <?php
+        $isUnlocked = service('premium_podcasts')
+            ->isUnlocked($podcast->handle);
+        $shownIcon = $isUnlocked ? 'lock-unlock' : 'lock';
+        $hiddenIcon = $isUnlocked ? 'lock' : 'lock-unlock';
+    ?>
+    <div class="flex flex-col items-center justify-between col-start-2 px-2 py-1 mt-2 sm:px-1 md:mt-4 rounded-conditional-full gap-y-2 sm:flex-row bg-accent-base gap-x-2 text-accent-contrast">
+        <p class="inline-flex items-center text-sm md:pl-4 gap-x-2"><?= $isUnlocked ? lang('PremiumPodcasts.banner_lock') : lang('PremiumPodcasts.banner_unlock') ?></p>
+        <?php if ($subscriptionLink = service('settings')->get('Subscription.link', 'podcast:' . $podcast->id)): ?>
+            <div class="flex items-center self-end gap-x-2">
+                <Button
+                    variant="primary"
+                    class="group"
+                    size="small"
+                    uri="<?= $isUnlocked ? route_to('premium-podcast-lock', $podcast->handle) : route_to('premium-podcast-unlock', $podcast->handle) ?>"
+                >
+                    <Icon glyph="<?= $shownIcon ?>" class="text-sm group-focus:hidden group-hover:hidden" />
+                    <Icon glyph="<?= $hiddenIcon ?>" class="hidden text-sm group-focus:block group-hover:block" />
+                    <?= $isUnlocked ? lang('PremiumPodcasts.lock') : lang('PremiumPodcasts.unlock') ?>
+                </Button>
+                <Button
+                    iconLeft="external-link"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    variant="secondary"
+                    size="small"
+                    class="tracking-wider uppercase"
+                    uri="<?= $subscriptionLink ?>"><?= lang('PremiumPodcasts.subscribe') ?></Button>
+            </div>
+        <?php else: ?>
+            <Button
+                variant="primary"
+                class="self-end group"
+                size="small"
+                uri="<?= $isUnlocked ? route_to('premium-podcast-lock', $podcast->handle) : route_to('premium-podcast-unlock', $podcast->handle) ?>"
+            >
+                <Icon glyph="<?= $shownIcon ?>" class="text-sm group-focus:hidden group-hover:hidden" />
+                <Icon glyph="<?= $hiddenIcon ?>" class="hidden text-sm group-focus:block group-hover:block" />
+                <?= $isUnlocked ? lang('PremiumPodcasts.lock') : lang('PremiumPodcasts.unlock') ?>
+            </Button>
+        <?php endif; ?>
+    </div>
+<?php endif; ?>
\ No newline at end of file
diff --git a/themes/cp_app/podcast/_partials/sidebar.php b/themes/cp_app/podcast/_partials/sidebar.php
index 0f178ba241..842eb22d0e 100644
--- a/themes/cp_app/podcast/_partials/sidebar.php
+++ b/themes/cp_app/podcast/_partials/sidebar.php
@@ -1,4 +1,4 @@
-<div data-sidebar-toggler="backdrop" class="absolute top-0 left-0 z-10 hidden w-full h-full bg-backdrop/75 md:hidden" role="button" tabIndex="0" aria-label="Close"></div>
+<div data-sidebar-toggler="backdrop" class="absolute top-0 left-0 z-10 hidden w-full h-full bg-backdrop/75 md:hidden" role="button" tabIndex="0" aria-label="<?= lang('Common.close') ?>"></div>
 <aside id="podcast-sidebar" data-sidebar-toggler="sidebar" data-toggle-class="hidden" data-hide-class="hidden" class="z-20 hidden h-full col-span-1 col-start-2 row-start-1 p-4 py-6 shadow-2xl md:shadow-none md:block bg-base">
     <div class="sticky z-10 bg-base top-12">
         <a href="<?= route_to('podcast_feed', esc($podcast->handle)) ?>" class="inline-flex items-center mb-6 text-sm font-semibold focus:ring-accent text-skin-muted hover:text-skin-base group" target="_blank" rel="noopener noreferrer">
diff --git a/themes/cp_app/podcast/activity.php b/themes/cp_app/podcast/activity.php
index dcca1117b2..56ab350af8 100644
--- a/themes/cp_app/podcast/activity.php
+++ b/themes/cp_app/podcast/activity.php
@@ -6,12 +6,12 @@
     <form action="<?= route_to('post-attempt-create', esc(interact_as_actor()->username)) ?>" method="POST" class="flex p-4 shadow bg-elevated gap-x-2 rounded-conditional-2xl">
     <?= csrf_field() ?>
 
-    <?= view('_message_block') ?>
-
+    
     <img src="<?= interact_as_actor()
         ->avatar_image_url ?>" alt="<?= esc(interact_as_actor()
         ->display_name) ?>" class="w-10 h-10 rounded-full aspect-square" loading="lazy" />
     <div class="flex flex-col flex-1 min-w-0 gap-y-2">
+        <?= view('_message_block') ?>
         <Forms.Textarea
             name="message"
             required="true"
diff --git a/themes/cp_app/podcast/unlock.php b/themes/cp_app/podcast/unlock.php
new file mode 100644
index 0000000000..1e89af1513
--- /dev/null
+++ b/themes/cp_app/podcast/unlock.php
@@ -0,0 +1,105 @@
+<?= helper('page') ?>
+
+<!DOCTYPE html>
+<html lang="<?= service('request')
+    ->getLocale() ?>">
+
+<head>
+    <meta charset="UTF-8"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+    <link rel="icon" type="image/x-icon" href="<?= service('settings')
+    ->get('App.siteIcon')['ico'] ?>" />
+    <link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
+    <link rel="manifest" href="<?= route_to('podcast-webmanifest', esc($podcast->handle)) ?>">
+    <meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>">
+    <script>
+    // Check that service workers are supported
+    if ('serviceWorker' in navigator) {
+        // Use the window load event to keep the page load performant
+        window.addEventListener('load', () => {
+            navigator.serviceWorker.register('/sw.js');
+        });
+    }
+    </script>
+
+    <?= $metatags ?>
+
+    <link rel='stylesheet' type='text/css' href='<?= route_to('themes-colors-css') ?>' />
+    <?= service('vite')
+        ->asset('styles/index.css', 'css') ?>
+    <?= service('vite')
+        ->asset('js/app.ts', 'js') ?>
+</head>
+
+<body class="overflow-hidden flex flex-col min-h-screen mx-auto md:min-h-full md:grid md:grid-cols-podcast bg-base theme-<?= service('settings')
+        ->get('App.theme') ?>">
+    <?php if (can_user_interact()): ?>
+        <div class="col-span-full">
+            <?= $this->include('_admin_navbar') ?>
+        </div>
+    <?php endif; ?>
+
+    <div class="fixed z-50 flex flex-col items-center justify-center w-full h-full px-4 bg-accent-base/30 backdrop-blur-md">
+        <a class="absolute w-full h-full" href="<?= current_url() === previous_url() ? route_to('podcast-activity', $podcast->handle) : previous_url() ?>"><span class="sr-only"><?= lang('Common.go_back') ?></span></a>
+        <form class="z-10 flex flex-col items-center w-full max-w-lg p-8 text-center rounded-lg shadow-xl bg-elevated" action="<?= route_to('premium-podcast-unlock', $podcast->handle) ?>" method="POST">
+            <?= csrf_field() ?>
+            <Icon class="p-4 text-6xl rounded-full bg-base text-accent-base" glyph="lock" />
+            <Heading tagName="h1" size="large" class="mt-2"><?= lang('PremiumPodcasts.unlock_form.title') ?></Heading>
+            <p class="max-w-sm text-skin-muted"><?= lang('PremiumPodcasts.unlock_form.subtitle', [
+                'podcastTitle' => esc($podcast->title),
+            ]) ?></p>
+            <?= view('_message_block') ?>
+            <Forms.Field
+                class="self-stretch mt-4 text-left"
+                name="token"
+                type="password"
+                label="<?= lang('PremiumPodcasts.unlock_form.token') ?>"
+                hint="<?= lang('PremiumPodcasts.unlock_form.token_hint', [
+                    'podcastTitle' => esc($podcast->title),
+                ]) ?>"
+                required="true"
+            />
+            <Button type="submit" variant="primary" iconLeft="lock-unlock" class="self-center mt-2"><?= lang('PremiumPodcasts.unlock_form.submit') ?></Button>
+            <?php if ($subscriptionLink = service('settings')
+                ->get('Subscription.link', 'podcast:' . $podcast->id)): ?>
+                <p class="max-w-xs mt-4 text-xs">
+                    <?= lang('PremiumPodcasts.unlock_form.call_to_action', [
+                        'podcastTitle' => esc($podcast->title),
+                    ]) ?>
+                    <a href="<?= $subscriptionLink ?>" target="_blank" rel="noopener noreferrer" class="font-semibold underline hover:no-underline"><?= lang('PremiumPodcasts.unlock_form.subscribe_cta') ?></a>
+                </p>
+            <?php endif; ?>
+        </form>
+    </div>
+
+    <header class="relative flex flex-col-reverse justify-between w-full col-start-2 bg-top bg-no-repeat bg-cover sm:flex-row sm:items-end bg-header aspect-[3/1]" style="background-image: url('<?= $podcast->banner->medium_url ?>');">
+        <div class="absolute bottom-0 left-0 w-full h-full backdrop-gradient mix-blend-multiply"></div>
+        <div class="flex items-center pl-4 -mb-6 md:pl-8 md:-mb-8 gap-x-4">
+            <img src="<?= $podcast->cover->thumbnail_url ?>" alt="<?= esc($podcast->title) ?>" class="h-24 rounded-full sm:h-28 md:h-36 ring-3 ring-background-elevated aspect-square" loading="lazy" />
+            <div class="relative flex flex-col text-white -top-3 sm:top-0 md:top-2">
+                <h1 class="text-lg font-bold leading-none line-clamp-2 md:leading-none md:text-2xl font-display"><?= esc($podcast->title) ?><span class="ml-1 font-sans text-base font-normal">@<?= esc($podcast->handle) ?></span></h1>
+                <div class="">
+                    <?= explicit_badge($podcast->parental_advisory === 'explicit', 'mr-1') ?>
+                    <span class="text-xs"><?= lang('Podcast.followers', [
+                        'numberOfFollowers' => $podcast->actor->followers_count,
+                    ]) ?></span>
+                </div>
+            </div>
+        </div>
+        <div class="inline-flex items-center self-end mt-2 mr-2 sm:mb-4 sm:mr-4 gap-x-2">
+            <?php if (in_array(true, array_column($podcast->fundingPlatforms, 'is_visible'), true)): ?>
+                <button class="p-2 text-red-600 bg-white rounded-full shadow hover:text-red-500 focus:ring-accent" data-toggle="funding-links" data-toggle-class="hidden" data-tooltip="bottom" title="<?= lang('Podcast.sponsor') ?>"><Icon glyph="heart"></Icon></button>
+            <?php endif; ?>
+        </div>
+    </header>
+    <?= $this->include('podcast/_partials/navigation') ?>
+    <div class="relative grid items-start flex-1 col-start-2 grid-cols-podcastMain gap-x-6">
+        <main class="w-full max-w-xl col-start-1 row-start-1 py-6 mx-auto col-span-full md:col-span-1"></main>
+        <?= $this->include('podcast/_partials/sidebar') ?>
+    </div>
+
+    <?php if (in_array(true, array_column($podcast->fundingPlatforms, 'is_visible'), true)): ?>
+        <?= $this->include('podcast/_partials/funding_links_modal') ?>
+    <?php endif; ?>
+
+</body>
-- 
GitLab