From 93e605b40639e251ce727f17551f37e529648869 Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Mon, 17 May 2021 17:11:23 +0000
Subject: [PATCH] refactor(persons): move podcast and episode persons models to
 person model for consistency

- fix lazy loading properties + podcast import controller
- rename all snake_case variables to
camelCase
- fix broken routes
- refactor Location construction logic and setters
---
 app/Config/Routes.php                         |   6 +-
 app/Controllers/Admin/EpisodeController.php   |  37 +--
 .../Admin/EpisodePersonController.php         |  14 +-
 app/Controllers/Admin/PersonController.php    |   9 +-
 app/Controllers/Admin/PodcastController.php   |   9 +-
 .../Admin/PodcastImportController.php         | 147 ++++++-----
 .../Admin/PodcastPersonController.php         |  14 +-
 app/Controllers/PageController.php            |  50 ++--
 .../2020-05-30-101500_add_podcasts.php        |   2 +-
 .../2020-06-05-170000_add_episodes.php        |   2 +-
 .../2020-06-05-180000_add_soundbites.php      |   3 +-
 ...2020-12-25-140000_add_episodes_persons.php |   2 +-
 .../Seeds/FakePodcastsAnalyticsSeeder.php     |  42 ++--
 .../Seeds/FakeWebsiteAnalyticsSeeder.php      |  24 +-
 app/Entities/Actor.php                        |   2 +-
 app/Entities/Category.php                     |   2 +-
 app/Entities/Credit.php                       |   6 +-
 app/Entities/Episode.php                      |  92 ++++---
 app/Entities/Image.php                        |   2 +-
 app/Entities/Location.php                     |  73 +++++-
 app/Entities/Note.php                         |   2 +-
 app/Entities/Person.php                       |  33 ++-
 app/Entities/Podcast.php                      |  69 +++---
 app/Entities/User.php                         |   4 +-
 app/Helpers/components_helper.php             |  61 ++++-
 app/Helpers/form_helper.php                   |  12 +-
 app/Helpers/id3_helper.php                    |   8 +-
 app/Helpers/location_helper.php               |  62 -----
 app/Helpers/misc_helper.php                   |   4 +-
 app/Helpers/rss_helper.php                    | 105 ++++----
 app/Helpers/svg_helper.php                    |  16 +-
 app/Helpers/url_helper.php                    |  16 --
 app/Libraries/ActivityPub/ActivityRequest.php |   2 +-
 .../Helpers/activitypub_helper.php            |   8 +-
 app/Libraries/ActivityPub/HttpSignature.php   |   2 +-
 .../Controllers/AnalyticsController.php       |  15 +-
 app/Libraries/SimpleRSSElement.php            |  16 +-
 app/Models/EpisodeModel.php                   |   2 +-
 app/Models/PersonModel.php                    | 232 ++++++++++--------
 app/Models/PodcastModel.php                   |  14 +-
 .../admin/episode/{person.php => persons.php} |  58 +++--
 app/Views/admin/episode/soundbites.php        |  52 ++--
 app/Views/admin/podcast/person.php            | 135 ----------
 app/Views/admin/podcast/persons.php           | 129 ++++++++++
 app/Views/errors/html/error_exception.php     |   8 +-
 app/Views/podcast/_layout_authenticated.php   |   2 +-
 app/Views/podcast/_partials/header.php        |  32 +--
 app/Views/podcast/episode.php                 |  33 +--
 48 files changed, 844 insertions(+), 826 deletions(-)
 delete mode 100644 app/Helpers/location_helper.php
 rename app/Views/admin/episode/{person.php => persons.php} (58%)
 delete mode 100644 app/Views/admin/podcast/person.php
 create mode 100644 app/Views/admin/podcast/persons.php

diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index a279b940ff..4539867c1e 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -164,13 +164,13 @@ $routes->group(
                 ]);
 
                 $routes->group('persons', function ($routes): void {
-                    $routes->get('/', 'PodcastPodcastController/$1', [
+                    $routes->get('/', 'PodcastPersonController/$1', [
                         'as' => 'podcast-person-manage',
                         'filter' => 'permission:podcast-edit',
                     ]);
                     $routes->post(
                         '/',
-                        'PodcastPodcastController::attemptAdd/$1',
+                        'PodcastPersonController::attemptAdd/$1',
                         [
                             'filter' => 'permission:podcast-edit',
                         ],
@@ -178,7 +178,7 @@ $routes->group(
 
                     $routes->get(
                         '(:num)/remove',
-                        'PodcastPodcastController::remove/$1/$2',
+                        'PodcastPersonController::remove/$1/$2',
                         [
                             'as' => 'podcast-person-remove',
                             'filter' => 'permission:podcast-edit',
diff --git a/app/Controllers/Admin/EpisodeController.php b/app/Controllers/Admin/EpisodeController.php
index 423d1b3ba9..4eb61cf2ac 100644
--- a/app/Controllers/Admin/EpisodeController.php
+++ b/app/Controllers/Admin/EpisodeController.php
@@ -12,6 +12,7 @@ use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
 use Config\Database;
 use App\Entities\Episode;
+use App\Entities\Location;
 use App\Entities\Note;
 use App\Entities\Podcast;
 use App\Models\EpisodeModel;
@@ -133,7 +134,9 @@ class EpisodeController extends BaseController
             'audio_file' => $this->request->getFile('audio_file'),
             'description_markdown' => $this->request->getPost('description'),
             'image' => $this->request->getFile('image'),
-            'location' => $this->request->getPost('location_name'),
+            'location' => new Location(
+                $this->request->getPost('location_name'),
+            ),
             'transcript' => $this->request->getFile('transcript'),
             'chapters' => $this->request->getFile('chapters'),
             'parental_advisory' =>
@@ -249,7 +252,9 @@ class EpisodeController extends BaseController
         $this->episode->description_markdown = $this->request->getPost(
             'description',
         );
-        $this->episode->location = $this->request->getPost('location_name');
+        $this->episode->location = new Location(
+            $this->request->getPost('location_name'),
+        );
         $this->episode->parental_advisory =
             $this->request->getPost('parental_advisory') !== 'undefined'
                 ? $this->request->getPost('parental_advisory')
@@ -673,17 +678,17 @@ class EpisodeController extends BaseController
 
     public function soundbitesAttemptEdit(): RedirectResponse
     {
-        $soundbites_array = $this->request->getPost('soundbites_array');
+        $soundbites = $this->request->getPost('soundbites');
         $rules = [
-            'soundbites_array.0.start_time' =>
-                'permit_empty|required_with[soundbites_array.0.duration]|decimal|greater_than_equal_to[0]',
-            'soundbites_array.0.duration' =>
-                'permit_empty|required_with[soundbites_array.0.start_time]|decimal|greater_than_equal_to[0]',
+            'soundbites.0.start_time' =>
+                'permit_empty|required_with[soundbites.0.duration]|decimal|greater_than_equal_to[0]',
+            'soundbites.0.duration' =>
+                'permit_empty|required_with[soundbites.0.start_time]|decimal|greater_than_equal_to[0]',
         ];
-        foreach (array_keys($soundbites_array) as $soundbite_id) {
+        foreach (array_keys($soundbites) as $soundbite_id) {
             $rules += [
-                "soundbites_array.{$soundbite_id}.start_time" => 'required|decimal|greater_than_equal_to[0]',
-                "soundbites_array.{$soundbite_id}.duration" => 'required|decimal|greater_than_equal_to[0]',
+                "soundbites.{$soundbite_id}.start_time" => 'required|decimal|greater_than_equal_to[0]',
+                "soundbites.{$soundbite_id}.duration" => 'required|decimal|greater_than_equal_to[0]',
             ];
         }
         if (!$this->validate($rules)) {
@@ -693,16 +698,13 @@ class EpisodeController extends BaseController
                 ->with('errors', $this->validator->getErrors());
         }
 
-        foreach ($soundbites_array as $soundbite_id => $soundbite) {
-            if (
-                $soundbite['start_time'] !== null &&
-                $soundbite['duration'] !== null
-            ) {
+        foreach ($soundbites as $soundbite_id => $soundbite) {
+            if ((int) $soundbite['start_time'] < (int) $soundbite['duration']) {
                 $data = [
                     'podcast_id' => $this->podcast->id,
                     'episode_id' => $this->episode->id,
-                    'start_time' => $soundbite['start_time'],
-                    'duration' => $soundbite['duration'],
+                    'start_time' => (int) $soundbite['start_time'],
+                    'duration' => (int) $soundbite['duration'],
                     'label' => $soundbite['label'],
                     'updated_by' => user_id(),
                 ];
@@ -711,6 +713,7 @@ class EpisodeController extends BaseController
                 } else {
                     $data += ['id' => $soundbite_id];
                 }
+
                 $soundbiteModel = new SoundbiteModel();
                 if (!$soundbiteModel->save($data)) {
                     return redirect()
diff --git a/app/Controllers/Admin/EpisodePersonController.php b/app/Controllers/Admin/EpisodePersonController.php
index c38c571c42..caec435d00 100644
--- a/app/Controllers/Admin/EpisodePersonController.php
+++ b/app/Controllers/Admin/EpisodePersonController.php
@@ -23,7 +23,7 @@ class EpisodePersonController extends BaseController
 
     public function _remap(string $method, string ...$params): mixed
     {
-        if (count($params) <= 2) {
+        if (count($params) < 2) {
             throw PageNotFoundException::forPageNotFound();
         }
 
@@ -54,10 +54,6 @@ class EpisodePersonController extends BaseController
         $data = [
             'episode' => $this->episode,
             'podcast' => $this->podcast,
-            'episodePersons' => (new PersonModel())->getEpisodePersons(
-                $this->podcast->id,
-                $this->episode->id,
-            ),
             'personOptions' => (new PersonModel())->getPersonOptions(),
             'taxonomyOptions' => (new PersonModel())->getTaxonomyOptions(),
         ];
@@ -65,7 +61,7 @@ class EpisodePersonController extends BaseController
             0 => $this->podcast->title,
             1 => $this->episode->title,
         ]);
-        return view('admin/episode/person', $data);
+        return view('admin/episode/persons', $data);
     }
 
     public function attemptAdd(): RedirectResponse
@@ -91,12 +87,12 @@ class EpisodePersonController extends BaseController
         return redirect()->back();
     }
 
-    public function remove(int $episodePersonId): RedirectResponse
+    public function remove(int $personId): RedirectResponse
     {
-        (new PersonModel())->removeEpisodePersons(
+        (new PersonModel())->removePersonFromEpisode(
             $this->podcast->id,
             $this->episode->id,
-            $episodePersonId,
+            $personId,
         );
 
         return redirect()->back();
diff --git a/app/Controllers/Admin/PersonController.php b/app/Controllers/Admin/PersonController.php
index 7d32264b30..6e07467def 100644
--- a/app/Controllers/Admin/PersonController.php
+++ b/app/Controllers/Admin/PersonController.php
@@ -8,6 +8,7 @@
 
 namespace App\Controllers\Admin;
 
+use App\Entities\Image;
 use CodeIgniter\HTTP\RedirectResponse;
 use App\Entities\Person;
 use CodeIgniter\Exceptions\PageNotFoundException;
@@ -77,7 +78,7 @@ class PersonController extends BaseController
             'full_name' => $this->request->getPost('full_name'),
             'unique_name' => $this->request->getPost('unique_name'),
             'information_url' => $this->request->getPost('information_url'),
-            'image' => $this->request->getFile('image'),
+            'image' => new Image($this->request->getFile('image')),
             'created_by' => user_id(),
             'updated_by' => user_id(),
         ]);
@@ -125,9 +126,9 @@ class PersonController extends BaseController
         $this->person->information_url = $this->request->getPost(
             'information_url',
         );
-        $image = $this->request->getFile('image');
-        if ($image->isValid()) {
-            $this->person->image = $image;
+        $imageFile = $this->request->getFile('image');
+        if ($imageFile !== null && $imageFile->isValid()) {
+            $this->person->image = new Image($imageFile);
         }
 
         $this->person->updated_by = user_id();
diff --git a/app/Controllers/Admin/PodcastController.php b/app/Controllers/Admin/PodcastController.php
index 47172c908f..cbeb6022d1 100644
--- a/app/Controllers/Admin/PodcastController.php
+++ b/app/Controllers/Admin/PodcastController.php
@@ -9,6 +9,7 @@
 namespace App\Controllers\Admin;
 
 use App\Entities\Image;
+use App\Entities\Location;
 use App\Entities\Podcast;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use Config\Database;
@@ -170,7 +171,9 @@ class PodcastController extends BaseController
             'publisher' => $this->request->getPost('publisher'),
             'type' => $this->request->getPost('type'),
             'copyright' => $this->request->getPost('copyright'),
-            'location' => $this->request->getPost('location_name'),
+            'location' => new Location(
+                $this->request->getPost('location_name'),
+            ),
             'payment_pointer' => $this->request->getPost('payment_pointer'),
             'custom_rss_string' => $this->request->getPost('custom_rss'),
             'partner_id' => $this->request->getPost('partner_id'),
@@ -271,7 +274,9 @@ class PodcastController extends BaseController
         $this->podcast->owner_email = $this->request->getPost('owner_email');
         $this->podcast->type = $this->request->getPost('type');
         $this->podcast->copyright = $this->request->getPost('copyright');
-        $this->podcast->location = $this->request->getPost('location_name');
+        $this->podcast->location = new Location(
+            $this->request->getPost('location_name'),
+        );
         $this->podcast->payment_pointer = $this->request->getPost(
             'payment_pointer',
         );
diff --git a/app/Controllers/Admin/PodcastImportController.php b/app/Controllers/Admin/PodcastImportController.php
index 75ec683294..f94997b25a 100644
--- a/app/Controllers/Admin/PodcastImportController.php
+++ b/app/Controllers/Admin/PodcastImportController.php
@@ -16,6 +16,7 @@ use Config\Database;
 use Podlibre\PodcastNamespace\ReversedTaxonomy;
 use App\Entities\Episode;
 use App\Entities\Image;
+use App\Entities\Location;
 use App\Entities\Person;
 use App\Models\CategoryModel;
 use App\Models\LanguageModel;
@@ -121,7 +122,7 @@ class PodcastImportController extends BaseController
 
         try {
             if (
-                $nsItunes->image !== null &&
+                isset($nsItunes->image) &&
                 $nsItunes->image->attributes()['href'] !== null
             ) {
                 $imageFile = download_file(
@@ -133,6 +134,15 @@ class PodcastImportController extends BaseController
                 );
             }
 
+            $location = null;
+            if (isset($nsPodcast->location)) {
+                $location = new Location(
+                    (string) $nsPodcast->location,
+                    (string) $nsPodcast->location->attributes()['geo'],
+                    (string) $nsPodcast->location->attributes()['osm'],
+                );
+            }
+
             $podcast = new Podcast([
                 'name' => $this->request->getPost('name'),
                 'imported_feed_url' => $this->request->getPost(
@@ -150,40 +160,27 @@ class PodcastImportController extends BaseController
                 'language_code' => $this->request->getPost('language'),
                 'category_id' => $this->request->getPost('category'),
                 'parental_advisory' =>
-                $nsItunes->explicit === null
-                    ? null
-                    : (in_array($nsItunes->explicit, ['yes', 'true'])
+                isset($nsItunes->explicit)
+                    ? (in_array((string) $nsItunes->explicit, ['yes', 'true'])
                         ? 'explicit'
-                        : (in_array($nsItunes->explicit, ['no', 'false'])
+                        : (in_array((string) $nsItunes->explicit, ['no', 'false'])
                             ? 'clean'
-                            : null)),
+                            : null))
+                    : null,
                 'owner_name' => (string) $nsItunes->owner->name,
                 'owner_email' => (string) $nsItunes->owner->email,
                 'publisher' => (string) $nsItunes->author,
-                'type' =>
-                $nsItunes->type === null ? 'episodic' : $nsItunes->type,
+                'type' => isset($nsItunes->type) ? (string) $nsItunes->type : 'episodic',
                 'copyright' => (string) $feed->channel[0]->copyright,
                 'is_blocked' =>
-                $nsItunes->block === null
-                    ? false
-                    : $nsItunes->block === 'yes',
+                isset($nsItunes->block)
+                    ? (string) $nsItunes->block === 'yes'
+                    : false,
                 'is_completed' =>
-                $nsItunes->complete === null
-                    ? false
-                    : $nsItunes->complete === 'yes',
-                'location_name' => $nsPodcast->location
-                    ? (string) $nsPodcast->location
-                    : null,
-                'location_geo' =>
-                !$nsPodcast->location ||
-                    $nsPodcast->location->attributes()['geo'] === null
-                    ? null
-                    : (string) $nsPodcast->location->attributes()['geo'],
-                'location_osm_id' =>
-                !$nsPodcast->location ||
-                    $nsPodcast->location->attributes()['osm'] === null
-                    ? null
-                    : (string) $nsPodcast->location->attributes()['osm'],
+                isset($nsItunes->complete)
+                    ? (string) $nsItunes->complete === 'yes'
+                    : false,
+                'location' => $location,
                 'created_by' => user_id(),
                 'updated_by' => user_id(),
             ]);
@@ -277,18 +274,17 @@ class PodcastImportController extends BaseController
                 }
             }
 
-            $personGroup =
-                isset($podcastPerson->attributes()['group'])
-                ? ['slug' => '']
-                : ReversedTaxonomy::$taxonomy[(string) $podcastPerson->attributes()['group']];
-            $personRole =
-                isset($podcastPerson->attributes()['role']) ||
-                $personGroup === null
-                ? ['slug' => '']
-                : $personGroup['roles'][strval($podcastPerson->attributes()['role'])];
+            // TODO: these checks should be in the taxonomy as default values
+            $podcastPersonGroup = $podcastPerson->attributes()['group'] ?? "Cast";
+            $podcastPersonRole = $podcastPerson->attributes()['role'] ?? "Host";
+
+            $personGroup = ReversedTaxonomy::$taxonomy[(string) $podcastPersonGroup];
+
+            $personGroupSlug = $personGroup['slug'];
+            $personRoleSlug = $personGroup['roles'][(string) $podcastPersonRole]['slug'];
 
             $podcastPersonModel = new PersonModel();
-            if (!$podcastPersonModel->addPodcastPerson($newPodcastId, $newPersonId, $personGroup['slug'], $personRole['slug'])) {
+            if (!$podcastPersonModel->addPodcastPerson($newPodcastId, $newPersonId, $personGroupSlug, $personRoleSlug)) {
                 return redirect()
                     ->back()
                     ->withInput()
@@ -341,7 +337,7 @@ class PodcastImportController extends BaseController
             };
 
             if (
-                $nsItunes->image !== null &&
+                isset($nsItunes->image) &&
                 $nsItunes->image->attributes()['href'] !== null
             ) {
                 $episodeImage = new Image(
@@ -353,6 +349,15 @@ class PodcastImportController extends BaseController
                 $episodeImage = null;
             }
 
+            $location = null;
+            if (isset($nsPodcast->location)) {
+                $location = new Location(
+                    (string) $nsPodcast->location,
+                    (string) $nsPodcast->location->attributes()['geo'],
+                    (string) $nsPodcast->location->attributes()['osm'],
+                );
+            }
+
             $newEpisode = new Episode([
                 'podcast_id' => $newPodcastId,
                 'guid' => $item->guid ?? null,
@@ -367,13 +372,13 @@ class PodcastImportController extends BaseController
                 'description_html' => $itemDescriptionHtml,
                 'image' => $episodeImage,
                 'parental_advisory' =>
-                $nsItunes->explicit === null
-                    ? null
-                    : (in_array($nsItunes->explicit, ['yes', 'true'])
+                isset($nsItunes->explicit)
+                    ? (in_array((string) $nsItunes->explicit, ['yes', 'true'])
                         ? 'explicit'
-                        : (in_array($nsItunes->explicit, ['no', 'false'])
+                        : (in_array((string) $nsItunes->explicit, ['no', 'false'])
                             ? 'clean'
-                            : null)),
+                            : null))
+                    : null,
                 'number' =>
                 $this->request->getPost('force_renumber') === 'yes'
                     ? $itemNumber
@@ -382,27 +387,13 @@ class PodcastImportController extends BaseController
                 $this->request->getPost('season_number') === null
                     ? $nsItunes->season
                     : $this->request->getPost('season_number'),
-                'type' =>
-                $nsItunes->episodeType === null
-                    ? 'full'
-                    : $nsItunes->episodeType,
-                'is_blocked' =>
-                $nsItunes->block === null
-                    ? false
-                    : $nsItunes->block === 'yes',
-                'location_name' => $nsPodcast->location
-                    ? $nsPodcast->location
-                    : null,
-                'location_geo' =>
-                !$nsPodcast->location ||
-                    $nsPodcast->location->attributes()['geo'] === null
-                    ? null
-                    : $nsPodcast->location->attributes()['geo'],
-                'location_osm_id' =>
-                !$nsPodcast->location ||
-                    $nsPodcast->location->attributes()['osm'] === null
-                    ? null
-                    : $nsPodcast->location->attributes()['osm'],
+                'type' => isset($nsItunes->episodeType)
+                    ? (string) $nsItunes->episodeType
+                    : 'full',
+                'is_blocked' => isset($nsItunes->block)
+                    ? (string) $nsItunes->block === 'yes'
+                    : false,
+                'location' => $location,
                 'created_by' => user_id(),
                 'updated_by' => user_id(),
                 'published_at' => strtotime($item->pubDate),
@@ -425,14 +416,16 @@ class PodcastImportController extends BaseController
                 if (($newPerson = $personModel->getPerson($fullName)) !== null) {
                     $newPersonId = $newPerson->id;
                 } else {
-                    $newEpisodePerson = new Person([
+                    $newPerson = new Person([
                         'full_name' => $fullName,
-                        'slug' => slugify($fullName),
+                        'unique_name' => slugify($fullName),
                         'information_url' => $episodePerson->attributes()['href'],
-                        'image' => new Image(download_file($episodePerson->attributes()['img']))
+                        'image' => new Image(download_file($episodePerson->attributes()['img'])),
+                        'created_by' => user_id(),
+                        'updated_by' => user_id(),
                     ]);
 
-                    if (!($newPersonId = $personModel->insert($newEpisodePerson))) {
+                    if (!($newPersonId = $personModel->insert($newPerson))) {
                         return redirect()
                             ->back()
                             ->withInput()
@@ -440,19 +433,17 @@ class PodcastImportController extends BaseController
                     }
                 }
 
-                $personGroup =
-                    $episodePerson->attributes()['group'] === null
-                    ? ['slug' => '']
-                    : ReversedTaxonomy::$taxonomy[strval($episodePerson->attributes()['group'])];
-                $personRole =
-                    $episodePerson->attributes()['role'] === null ||
-                    $personGroup === null
-                    ? ['slug' => '']
-                    : $personGroup['roles'][strval($episodePerson->attributes()['role'])];
+                // TODO: these checks should be in the taxonomy as default values
+                $episodePersonGroup = $episodePerson->attributes()['group'] ?? "Cast";
+                $episodePersonRole = $episodePerson->attributes()['role'] ?? "Host";
+
+                $personGroup = ReversedTaxonomy::$taxonomy[(string) $episodePersonGroup];
 
+                $personGroupSlug = $personGroup['slug'];
+                $personRoleSlug = $personGroup['roles'][(string) $episodePersonRole]['slug'];
 
                 $episodePersonModel = new PersonModel();
-                if (!$episodePersonModel->addEpisodePerson($newPodcastId, $newEpisodeId, $newPersonId, $personGroup['slug'], $personRole['slug'])) {
+                if (!$episodePersonModel->addEpisodePerson($newPodcastId, $newEpisodeId, $newPersonId, $personGroupSlug, $personRoleSlug)) {
                     return redirect()
                         ->back()
                         ->withInput()
diff --git a/app/Controllers/Admin/PodcastPersonController.php b/app/Controllers/Admin/PodcastPersonController.php
index 623088a95a..3b6a8147b6 100644
--- a/app/Controllers/Admin/PodcastPersonController.php
+++ b/app/Controllers/Admin/PodcastPersonController.php
@@ -54,13 +54,13 @@ class PodcastPersonController extends BaseController
         replace_breadcrumb_params([
             0 => $this->podcast->title,
         ]);
-        return view('admin/podcast/person', $data);
+        return view('admin/podcast/persons', $data);
     }
 
     public function attemptAdd(): RedirectResponse
     {
         $rules = [
-            'person' => 'required',
+            'persons' => 'required',
         ];
 
         if (!$this->validate($rules)) {
@@ -72,18 +72,18 @@ class PodcastPersonController extends BaseController
 
         (new PersonModel())->addPodcastPersons(
             $this->podcast->id,
-            $this->request->getPost('person'),
-            $this->request->getPost('person_group_role'),
+            $this->request->getPost('persons'),
+            $this->request->getPost('roles') ?? [],
         );
 
         return redirect()->back();
     }
 
-    public function remove(int $podcastPersonId): RedirectResponse
+    public function remove(int $personId): RedirectResponse
     {
-        (new PersonModel())->removePodcastPersons(
+        (new PersonModel())->removePersonFromPodcast(
             $this->podcast->id,
-            $podcastPersonId,
+            $personId,
         );
 
         return redirect()->back();
diff --git a/app/Controllers/PageController.php b/app/Controllers/PageController.php
index 97d55c6652..8f02363ab6 100644
--- a/app/Controllers/PageController.php
+++ b/app/Controllers/PageController.php
@@ -69,26 +69,26 @@ class PageController extends BaseController
             $allCredits = (new CreditModel())->findAll();
 
             // Unlike the carpenter, we make a tree from a table:
-            $person_group = null;
-            $person_id = null;
-            $person_role = null;
+            $personGroup = null;
+            $personId = null;
+            $personRole = null;
             $credits = [];
             foreach ($allCredits as $credit) {
-                if ($person_group !== $credit->person_group) {
-                    $person_group = $credit->person_group;
-                    $person_id = $credit->person_id;
-                    $person_role = $credit->person_role;
-                    $credits[$person_group] = [
+                if ($personGroup !== $credit->person_group) {
+                    $personGroup = $credit->person_group;
+                    $personId = $credit->person_id;
+                    $personRole = $credit->person_role;
+                    $credits[$personGroup] = [
                         'group_label' => $credit->group_label,
                         'persons' => [
-                            $person_id => [
+                            $personId => [
                                 'full_name' => $credit->person->full_name,
                                 'thumbnail_url' =>
                                     $credit->person->image->thumbnail_url,
                                 'information_url' =>
                                     $credit->person->information_url,
                                 'roles' => [
-                                    $person_role => [
+                                    $personRole => [
                                         'role_label' => $credit->role_label,
                                         'is_in' => [
                                             [
@@ -97,7 +97,7 @@ class PageController extends BaseController
                                                     : $credit->podcast->link,
                                                 'title' => $credit->episode_id
                                                     ? (count($allPodcasts) > 1
-                                                            ? "{$credit->podcast->title} â–¸ "
+                                                            ? "{$credit->podcast->title} › "
                                                             : '') .
                                                         $credit->episode
                                                             ->title .
@@ -117,16 +117,16 @@ class PageController extends BaseController
                             ],
                         ],
                     ];
-                } elseif ($person_id !== $credit->person_id) {
-                    $person_id = $credit->person_id;
-                    $person_role = $credit->person_role;
-                    $credits[$person_group]['persons'][$person_id] = [
+                } elseif ($personId !== $credit->person_id) {
+                    $personId = $credit->person_id;
+                    $personRole = $credit->person_role;
+                    $credits[$personGroup]['persons'][$personId] = [
                         'full_name' => $credit->person->full_name,
                         'thumbnail_url' =>
                             $credit->person->image->thumbnail_url,
                         'information_url' => $credit->person->information_url,
                         'roles' => [
-                            $person_role => [
+                            $personRole => [
                                 'role_label' => $credit->role_label,
                                 'is_in' => [
                                     [
@@ -135,7 +135,7 @@ class PageController extends BaseController
                                             : $credit->podcast->link,
                                         'title' => $credit->episode_id
                                             ? (count($allPodcasts) > 1
-                                                    ? "{$credit->podcast->title} â–¸ "
+                                                    ? "{$credit->podcast->title} › "
                                                     : '') .
                                                 $credit->episode->title .
                                                 episode_numbering(
@@ -151,10 +151,10 @@ class PageController extends BaseController
                             ],
                         ],
                     ];
-                } elseif ($person_role !== $credit->person_role) {
-                    $person_role = $credit->person_role;
-                    $credits[$person_group]['persons'][$person_id]['roles'][
-                        $person_role
+                } elseif ($personRole !== $credit->person_role) {
+                    $personRole = $credit->person_role;
+                    $credits[$personGroup]['persons'][$personId]['roles'][
+                        $personRole
                     ] = [
                         'role_label' => $credit->role_label,
                         'is_in' => [
@@ -164,7 +164,7 @@ class PageController extends BaseController
                                     : $credit->podcast->link,
                                 'title' => $credit->episode_id
                                     ? (count($allPodcasts) > 1
-                                            ? "{$credit->podcast->title} â–¸ "
+                                            ? "{$credit->podcast->title} › "
                                             : '') .
                                         $credit->episode->title .
                                         episode_numbering(
@@ -178,15 +178,15 @@ class PageController extends BaseController
                         ],
                     ];
                 } else {
-                    $credits[$person_group]['persons'][$person_id]['roles'][
-                        $person_role
+                    $credits[$personGroup]['persons'][$personId]['roles'][
+                        $personRole
                     ]['is_in'][] = [
                         'link' => $credit->episode_id
                             ? $credit->episode->link
                             : $credit->podcast->link,
                         'title' => $credit->episode_id
                             ? (count($allPodcasts) > 1
-                                    ? "{$credit->podcast->title} â–¸ "
+                                    ? "{$credit->podcast->title} › "
                                     : '') .
                                 $credit->episode->title .
                                 episode_numbering(
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 aa32c8895b..e8eafd4445 100644
--- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
+++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
@@ -142,7 +142,7 @@ class AddPodcasts extends Migration
                 'constraint' => 32,
                 'null' => true,
             ],
-            'location_osm_id' => [
+            'location_osm' => [
                 'type' => 'VARCHAR',
                 'constraint' => 12,
                 'null' => 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 83ee1e4a32..d3d42aa575 100644
--- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php
+++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
@@ -137,7 +137,7 @@ class AddEpisodes extends Migration
                 'constraint' => 32,
                 'null' => true,
             ],
-            'location_osm_id' => [
+            'location_osm' => [
                 'type' => 'VARCHAR',
                 'constraint' => 12,
                 'null' => true,
diff --git a/app/Database/Migrations/2020-06-05-180000_add_soundbites.php b/app/Database/Migrations/2020-06-05-180000_add_soundbites.php
index 43b3126a06..32183c52b3 100644
--- a/app/Database/Migrations/2020-06-05-180000_add_soundbites.php
+++ b/app/Database/Migrations/2020-06-05-180000_add_soundbites.php
@@ -35,7 +35,8 @@ class AddSoundbites extends Migration
                 'type' => 'DECIMAL(8,3)',
             ],
             'duration' => [
-                'type' => 'DECIMAL(8,3)',
+                // soundbite duration cannot be higher than 9999,999 seconds ~ 2.77 hours
+                'type' => 'DECIMAL(7,3)',
             ],
             'label' => [
                 'type' => 'VARCHAR',
diff --git a/app/Database/Migrations/2020-12-25-140000_add_episodes_persons.php b/app/Database/Migrations/2020-12-25-140000_add_episodes_persons.php
index 1bff6c1d0a..9382b9ac88 100644
--- a/app/Database/Migrations/2020-12-25-140000_add_episodes_persons.php
+++ b/app/Database/Migrations/2020-12-25-140000_add_episodes_persons.php
@@ -44,7 +44,7 @@ class AddEpisodesPersons extends Migration
                 'constraint' => 32,
             ],
         ]);
-        $this->forge->addKey('id', true);
+        $this->forge->addPrimaryKey('id', true);
         $this->forge->addUniqueKey([
             'podcast_id',
             'episode_id',
diff --git a/app/Database/Seeds/FakePodcastsAnalyticsSeeder.php b/app/Database/Seeds/FakePodcastsAnalyticsSeeder.php
index b80ebd16e8..0a8e3d1560 100644
--- a/app/Database/Seeds/FakePodcastsAnalyticsSeeder.php
+++ b/app/Database/Seeds/FakePodcastsAnalyticsSeeder.php
@@ -52,12 +52,12 @@ class FakePodcastsAnalyticsSeeder extends Seeder
                 $date < strtotime('now');
                 $date = strtotime(date('Y-m-d', $date) . ' +1 day')
             ) {
-                $analytics_podcasts = [];
-                $analytics_podcasts_by_hour = [];
-                $analytics_podcasts_by_country = [];
-                $analytics_podcasts_by_episode = [];
-                $analytics_podcasts_by_player = [];
-                $analytics_podcasts_by_region = [];
+                $analyticsPodcasts = [];
+                $analyticsPodcastsByHour = [];
+                $analyticsPodcastsByCountry = [];
+                $analyticsPodcastsByEpisode = [];
+                $analyticsPodcastsByPlayer = [];
+                $analyticsPodcastsByRegion = [];
 
                 $episodes = (new EpisodeModel())
                     ->where([
@@ -72,9 +72,9 @@ class FakePodcastsAnalyticsSeeder extends Seeder
                     $probability1 = (int) floor(exp(3 - $age / 40)) + 1;
 
                     for (
-                        $num_line = 0;
-                        $num_line < rand(1, $probability1);
-                        ++$num_line
+                        $lineNumber = 0;
+                        $lineNumber < rand(1, $probability1);
+                        ++$lineNumber
                     ) {
                         $probability2 = (int) floor(exp(6 - $age / 20)) + 10;
 
@@ -129,7 +129,7 @@ class FakePodcastsAnalyticsSeeder extends Seeder
 
                         $hits = rand(0, $probability2);
 
-                        $analytics_podcasts[] = [
+                        $analyticsPodcasts[] = [
                             'podcast_id' => $podcast->id,
                             'date' => date('Y-m-d', $date),
                             'duration' => rand(60, 3600),
@@ -137,26 +137,26 @@ class FakePodcastsAnalyticsSeeder extends Seeder
                             'hits' => $hits,
                             'unique_listeners' => $hits,
                         ];
-                        $analytics_podcasts_by_hour[] = [
+                        $analyticsPodcastsByHour[] = [
                             'podcast_id' => $podcast->id,
                             'date' => date('Y-m-d', $date),
                             'hour' => rand(0, 23),
                             'hits' => $hits,
                         ];
-                        $analytics_podcasts_by_country[] = [
+                        $analyticsPodcastsByCountry[] = [
                             'podcast_id' => $podcast->id,
                             'date' => date('Y-m-d', $date),
                             'country_code' => $countryCode,
                             'hits' => $hits,
                         ];
-                        $analytics_podcasts_by_episode[] = [
+                        $analyticsPodcastsByEpisode[] = [
                             'podcast_id' => $podcast->id,
                             'date' => date('Y-m-d', $date),
                             'episode_id' => $episode->id,
                             'age' => $age,
                             'hits' => $hits,
                         ];
-                        $analytics_podcasts_by_player[] = [
+                        $analyticsPodcastsByPlayer[] = [
                             'podcast_id' => $podcast->id,
                             'date' => date('Y-m-d', $date),
                             'service' => $service,
@@ -166,7 +166,7 @@ class FakePodcastsAnalyticsSeeder extends Seeder
                             'is_bot' => $isBot,
                             'hits' => $hits,
                         ];
-                        $analytics_podcasts_by_region[] = [
+                        $analyticsPodcastsByRegion[] = [
                             'podcast_id' => $podcast->id,
                             'date' => date('Y-m-d', $date),
                             'country_code' => $countryCode,
@@ -180,27 +180,27 @@ class FakePodcastsAnalyticsSeeder extends Seeder
                 $this->db
                     ->table('analytics_podcasts')
                     ->ignore(true)
-                    ->insertBatch($analytics_podcasts);
+                    ->insertBatch($analyticsPodcasts);
                 $this->db
                     ->table('analytics_podcasts_by_hour')
                     ->ignore(true)
-                    ->insertBatch($analytics_podcasts_by_hour);
+                    ->insertBatch($analyticsPodcastsByHour);
                 $this->db
                     ->table('analytics_podcasts_by_country')
                     ->ignore(true)
-                    ->insertBatch($analytics_podcasts_by_country);
+                    ->insertBatch($analyticsPodcastsByCountry);
                 $this->db
                     ->table('analytics_podcasts_by_episode')
                     ->ignore(true)
-                    ->insertBatch($analytics_podcasts_by_episode);
+                    ->insertBatch($analyticsPodcastsByEpisode);
                 $this->db
                     ->table('analytics_podcasts_by_player')
                     ->ignore(true)
-                    ->insertBatch($analytics_podcasts_by_player);
+                    ->insertBatch($analyticsPodcastsByPlayer);
                 $this->db
                     ->table('analytics_podcasts_by_region')
                     ->ignore(true)
-                    ->insertBatch($analytics_podcasts_by_region);
+                    ->insertBatch($analyticsPodcastsByRegion);
             }
         } else {
             echo "COULD NOT POPULATE DATABASE:\n\tCreate a podcast with episodes first.\n";
diff --git a/app/Database/Seeds/FakeWebsiteAnalyticsSeeder.php b/app/Database/Seeds/FakeWebsiteAnalyticsSeeder.php
index 51697c9bca..21fbc8fad6 100644
--- a/app/Database/Seeds/FakeWebsiteAnalyticsSeeder.php
+++ b/app/Database/Seeds/FakeWebsiteAnalyticsSeeder.php
@@ -192,9 +192,9 @@ class FakeWebsiteAnalyticsSeeder extends Seeder
                 $date < strtotime('now');
                 $date = strtotime(date('Y-m-d', $date) . ' +1 day')
             ) {
-                $website_by_browser = [];
-                $website_by_entry_page = [];
-                $website_by_referer = [];
+                $websiteByBrowser = [];
+                $websiteByEntryPage = [];
+                $websiteByReferer = [];
 
                 $episodes = (new EpisodeModel())
                     ->where([
@@ -209,9 +209,9 @@ class FakeWebsiteAnalyticsSeeder extends Seeder
                     $probability1 = (int) floor(exp(3 - $age / 40)) + 1;
 
                     for (
-                        $num_line = 0;
-                        $num_line < rand(1, $probability1);
-                        ++$num_line
+                        $lineNumber = 0;
+                        $lineNumber < rand(1, $probability1);
+                        ++$lineNumber
                     ) {
                         $probability2 = (int) floor(exp(6 - $age / 20)) + 10;
 
@@ -228,19 +228,19 @@ class FakeWebsiteAnalyticsSeeder extends Seeder
 
                         $hits = rand(0, $probability2);
 
-                        $website_by_browser[] = [
+                        $websiteByBrowser[] = [
                             'podcast_id' => $podcast->id,
                             'date' => date('Y-m-d', $date),
                             'browser' => $browser,
                             'hits' => $hits,
                         ];
-                        $website_by_entry_page[] = [
+                        $websiteByEntryPage[] = [
                             'podcast_id' => $podcast->id,
                             'date' => date('Y-m-d', $date),
                             'entry_page_url' => $episode->link,
                             'hits' => $hits,
                         ];
-                        $website_by_referer[] = [
+                        $websiteByReferer[] = [
                             'podcast_id' => $podcast->id,
                             'date' => date('Y-m-d', $date),
                             'referer_url' =>
@@ -254,15 +254,15 @@ class FakeWebsiteAnalyticsSeeder extends Seeder
                 $this->db
                     ->table('analytics_website_by_browser')
                     ->ignore(true)
-                    ->insertBatch($website_by_browser);
+                    ->insertBatch($websiteByBrowser);
                 $this->db
                     ->table('analytics_website_by_entry_page')
                     ->ignore(true)
-                    ->insertBatch($website_by_entry_page);
+                    ->insertBatch($websiteByEntryPage);
                 $this->db
                     ->table('analytics_website_by_referer')
                     ->ignore(true)
-                    ->insertBatch($website_by_referer);
+                    ->insertBatch($websiteByReferer);
             }
         } else {
             echo "COULD NOT POPULATE DATABASE:\n\tCreate a podcast with episodes first.\n";
diff --git a/app/Entities/Actor.php b/app/Entities/Actor.php
index 34054745fe..bc2fde32fb 100644
--- a/app/Entities/Actor.php
+++ b/app/Entities/Actor.php
@@ -18,7 +18,7 @@ use RuntimeException;
  */
 class Actor extends ActivityPubActor
 {
-    protected ?Podcast $podcast;
+    protected ?Podcast $podcast = null;
     protected bool $is_podcast;
 
     public function getIsPodcast(): bool
diff --git a/app/Entities/Category.php b/app/Entities/Category.php
index a650d65156..afc4e26583 100644
--- a/app/Entities/Category.php
+++ b/app/Entities/Category.php
@@ -21,7 +21,7 @@ use CodeIgniter\Entity\Entity;
  */
 class Category extends Entity
 {
-    protected ?Category $parent;
+    protected ?Category $parent = null;
 
     /**
      * @var array<string, string>
diff --git a/app/Entities/Credit.php b/app/Entities/Credit.php
index 5c6ea62896..93934634ef 100644
--- a/app/Entities/Credit.php
+++ b/app/Entities/Credit.php
@@ -29,9 +29,9 @@ use CodeIgniter\Entity\Entity;
  */
 class Credit extends Entity
 {
-    protected ?Person $person;
-    protected ?Podcast $podcast;
-    protected ?Episode $episode;
+    protected ?Person $person = null;
+    protected ?Podcast $podcast = null;
+    protected ?Episode $episode = null;
     protected string $group_label;
     protected string $role_label;
 
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index 178baac572..a9d7265612 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -58,10 +58,10 @@ use RuntimeException;
  * @property int $season_number
  * @property string $type
  * @property bool $is_blocked
- * @property Location $location
+ * @property Location|null $location
  * @property string|null $location_name
  * @property string|null $location_geo
- * @property string|null $location_osm_id
+ * @property string|null $location_osm
  * @property array|null $custom_rss
  * @property string $custom_rss_string
  * @property int $favourites_total
@@ -90,7 +90,7 @@ class Episode extends Entity
     protected string $audio_file_opengraph_url;
     protected string $embeddable_player_url;
     protected Image $image;
-    protected ?string $description;
+    protected ?string $description = null;
     protected File $transcript_file;
     protected File $chapters_file;
 
@@ -109,9 +109,9 @@ class Episode extends Entity
      */
     protected $notes = [];
 
-    protected ?Location $location;
+    protected ?Location $location = null;
     protected string $custom_rss_string;
-    protected string $publication_status;
+    protected ?string $publication_status = null;
 
     /**
      * @var string[]
@@ -152,7 +152,7 @@ class Episode extends Entity
         'is_blocked' => 'boolean',
         'location_name' => '?string',
         'location_geo' => '?string',
-        'location_osm_id' => '?string',
+        'location_osm' => '?string',
         'custom_rss' => '?json-array',
         'favourites_total' => 'integer',
         'reblogs_total' => 'integer',
@@ -202,7 +202,7 @@ class Episode extends Entity
     {
         helper(['media', 'id3']);
 
-        $audio_metadata = get_file_tags($audioFile);
+        $audioMetadata = get_file_tags($audioFile);
 
         $this->attributes['audio_file_path'] = save_media(
             $audioFile,
@@ -210,11 +210,11 @@ class Episode extends Entity
             $this->attributes['slug'],
         );
         $this->attributes['audio_file_duration'] =
-            $audio_metadata['playtime_seconds'];
-        $this->attributes['audio_file_mimetype'] = $audio_metadata['mime_type'];
-        $this->attributes['audio_file_size'] = $audio_metadata['filesize'];
+            $audioMetadata['playtime_seconds'];
+        $this->attributes['audio_file_mimetype'] = $audioMetadata['mime_type'];
+        $this->attributes['audio_file_size'] = $audioMetadata['filesize'];
         $this->attributes['audio_file_header_size'] =
-            $audio_metadata['avdataoffset'];
+            $audioMetadata['avdataoffset'];
 
         return $this;
     }
@@ -471,10 +471,8 @@ class Episode extends Entity
             $this->getPodcast()->partner_link_url !== null &&
             $this->getPodcast()->partner_image_url !== null
         ) {
-            $descriptionHtml .= "<div><a href=\"{$this->getPartnerLink(
-                $serviceSlug,
-            )}\" rel=\"sponsored noopener noreferrer\" target=\"_blank\"><img src=\"{$this->getPartnerImageUrl(
-                $serviceSlug,
+            $descriptionHtml .= "<div><a href=\"{$this->getPartnerLink($serviceSlug,
+            )}\" rel=\"sponsored noopener noreferrer\" target=\"_blank\"><img src=\"{$this->getPartnerImageUrl($serviceSlug,
             )}\" alt=\"Partner image\" /></a></div>";
         }
 
@@ -504,47 +502,41 @@ class Episode extends Entity
 
     public function getPublicationStatus(): string
     {
-        if ($this->publication_status !== '') {
-            return $this->publication_status;
-        }
-
-        if ($this->published_at === null) {
-            return 'not_published';
-        }
-
-        helper('date');
-        if ($this->published_at->isBefore(Time::now())) {
-            return 'published';
+        if ($this->publication_status === null) {
+            if ($this->published_at === null) {
+                $this->publication_status = 'not_published';
+            } elseif ($this->published_at->isBefore(Time::now())) {
+                $this->publication_status = 'published';
+            } else {
+                $this->publication_status = 'scheduled';
+            }
         }
 
-        return 'scheduled';
+        return $this->publication_status;
     }
 
     /**
      * Saves the location name and fetches OpenStreetMap info
      */
-    public function setLocation(?string $newLocationName = null): static
+    public function setLocation(?Location $location = null): static
     {
-        if ($newLocationName === null) {
+        if ($location === null) {
             $this->attributes['location_name'] = null;
             $this->attributes['location_geo'] = null;
-            $this->attributes['location_osm_id'] = null;
-        }
+            $this->attributes['location_osm'] = null;
 
-        helper('location');
-
-        $oldLocationName = $this->attributes['location_name'];
+            return $this;
+        }
 
         if (
-            $oldLocationName === null ||
-            $oldLocationName !== $newLocationName
+            !isset($this->attributes['location_name']) ||
+            $this->attributes['location_name'] !== $location->name
         ) {
-            $this->attributes['location_name'] = $newLocationName;
+            $location->fetchOsmLocation();
 
-            if ($location = fetch_osm_location($newLocationName)) {
-                $this->attributes['location_geo'] = $location['geo'];
-                $this->attributes['location_osm_id'] = $location['osm_id'];
-            }
+            $this->attributes['location_name'] = $location->name;
+            $this->attributes['location_geo'] = $location->geo;
+            $this->attributes['location_osm'] = $location->osm;
         }
 
         return $this;
@@ -557,11 +549,11 @@ class Episode extends Entity
         }
 
         if ($this->location === null) {
-            $this->location = new Location([
-                'name' => $this->location_name,
-                'geo' => $this->location_geo,
-                'osm_id' => $this->location_osm_id,
-            ]);
+            $this->location = new Location(
+                $this->location_name,
+                $this->location_geo,
+                $this->location_osm,
+            );
         }
 
         return $this->location;
@@ -645,9 +637,9 @@ class Episode extends Entity
         }
 
         return rtrim($this->getPodcast()->partner_image_url, '/') .
-        '?pid=' .
-        $this->getPodcast()->partner_id .
-        '&guid=' .
-        urlencode($this->attributes['guid']);
+            '?pid=' .
+            $this->getPodcast()->partner_id .
+            '&guid=' .
+            urlencode($this->attributes['guid']);
     }
 }
diff --git a/app/Entities/Image.php b/app/Entities/Image.php
index 74196cad7a..ddbd7c2f6e 100644
--- a/app/Entities/Image.php
+++ b/app/Entities/Image.php
@@ -36,7 +36,7 @@ use RuntimeException;
 class Image extends Entity
 {
     protected Images $config;
-    protected ?File $file;
+    protected ?File $file = null;
     protected string $dirname;
     protected string $filename;
     protected string $extension;
diff --git a/app/Entities/Location.php b/app/Entities/Location.php
index bfce2ddf75..40f256fa8c 100644
--- a/app/Entities/Location.php
+++ b/app/Entities/Location.php
@@ -9,12 +9,13 @@
 namespace App\Entities;
 
 use CodeIgniter\Entity\Entity;
+use Config\Services;
 
 /**
  * @property string $url
  * @property string $name
  * @property string|null $geo
- * @property string|null $osm_id
+ * @property string|null $osm
  */
 class Location extends Entity
 {
@@ -23,15 +24,30 @@ class Location extends Entity
      */
     const OSM_URL = 'https://www.openstreetmap.org/';
 
+    /**
+     * @var string
+     */
+    const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/';
+
+    public function __construct(
+        protected string $name,
+        protected ?string $geo = null,
+        protected ?string $osm = null
+    ) {
+        parent::__construct([
+            'name' => $name,
+            'geo' => $geo,
+            'osm' => $osm
+        ]);
+    }
+
     public function getUrl(): string
     {
-        if ($this->osm_id !== null) {
+        if ($this->osm !== null) {
             return self::OSM_URL .
-                ['N' => 'node', 'W' => 'way', 'R' => 'relation'][
-                    substr($this->osm_id, 0, 1)
-                ] .
+                ['N' => 'node', 'W' => 'way', 'R' => 'relation'][substr($this->osm, 0, 1)] .
                 '/' .
-                substr($this->osm_id, 1);
+                substr($this->osm, 1);
         }
 
         if ($this->geo !== null) {
@@ -42,4 +58,49 @@ class Location extends Entity
 
         return self::OSM_URL . 'search?query=' . urlencode($this->name);
     }
+
+    /**
+     * Fetches places from Nominatim OpenStreetMap
+     *
+     * @return array<string, string>|null
+     */
+    public function fetchOsmLocation(): self
+    {
+        $client = Services::curlrequest();
+
+        $response = $client->request(
+            'GET',
+            self::NOMINATIM_URL .
+                'search.php?q=' .
+                urlencode($this->name) .
+                '&polygon_geojson=1&format=jsonv2',
+            [
+                'headers' => [
+                    'User-Agent' => 'Castopod/' . CP_VERSION,
+                    'Accept' => 'application/json',
+                ],
+            ],
+        );
+
+        $places = json_decode(
+            $response->getBody(),
+            false,
+            512,
+            JSON_THROW_ON_ERROR,
+        );
+
+        if ($places === []) {
+            return $this;
+        }
+
+        if (isset($places[0]->lat, $places[0]->lon)) {
+            $this->attributes['geo'] = "geo:{$places[0]->lat},{$places[0]->lon}";
+        }
+
+        if (isset($places[0]->osm_type, $places[0]->osm_id)) {
+            $this->attributes['osm'] = strtoupper(substr($places[0]->osm_type, 0, 1)) . $places[0]->osm_id;
+        }
+
+        return $this;
+    }
 }
diff --git a/app/Entities/Note.php b/app/Entities/Note.php
index eeec4e3fdd..21155da362 100644
--- a/app/Entities/Note.php
+++ b/app/Entities/Note.php
@@ -21,7 +21,7 @@ use RuntimeException;
  */
 class Note extends ActivityPubNote
 {
-    protected ?Episode $episode;
+    protected ?Episode $episode = null;
 
     /**
      * @var array<string, string>
diff --git a/app/Entities/Person.php b/app/Entities/Person.php
index 7bfd0b163f..eb15dbdc6a 100644
--- a/app/Entities/Person.php
+++ b/app/Entities/Person.php
@@ -8,7 +8,9 @@
 
 namespace App\Entities;
 
+use App\Models\PersonModel;
 use CodeIgniter\Entity\Entity;
+use RuntimeException;
 
 /**
  * @property int $id
@@ -20,16 +22,16 @@ use CodeIgniter\Entity\Entity;
  * @property string $image_mimetype
  * @property int $created_by
  * @property int $updated_by
- * @property string|null $group
- * @property string|null $role
- * @property Podcast|null $podcast
- * @property Episode|null $episode
+ * @property string[]|null $roles
  */
 class Person extends Entity
 {
     protected Image $image;
-    protected ?Podcast $podcast;
-    protected ?Episode $episode;
+
+    /**
+     * @var string[]|null
+     */
+    protected ?array $roles = null;
 
     /**
      * @var array<string, string>
@@ -43,8 +45,6 @@ class Person extends Entity
         'image_mimetype' => 'string',
         'podcast_id' => '?integer',
         'episode_id' => '?integer',
-        'group' => '?string',
-        'role' => '?string',
         'created_by' => 'integer',
         'updated_by' => 'integer',
     ];
@@ -73,4 +73,21 @@ class Person extends Entity
             $this->attributes['image_mimetype'],
         );
     }
+
+    /**
+     * @return stdClass[]
+     */
+    public function getRoles(): array {
+        if ($this->podcast_id === null) {
+            throw new RuntimeException(
+                'Person must have a podcast_id before getting roles.',
+            );
+        }
+
+        if ($this->roles === null) {
+            $this->roles = (new PersonModel())->getPersonRoles($this->id, $this->podcast_id, $this->episode_id);
+        }
+
+        return $this->roles;
+    }
 }
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 7b84d3df9f..55ba035675 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -27,7 +27,7 @@ use RuntimeException;
  * @property string $link
  * @property string $feed_url
  * @property string $title
- * @property string $description Holds text only description, striped of any markdown or html special characters
+ * @property string|null $description Holds text only description, striped of any markdown or html special characters
  * @property string $description_markdown
  * @property  string $description_html
  * @property Image $image
@@ -51,10 +51,10 @@ use RuntimeException;
  * @property bool $is_locked
  * @property string|null $imported_feed_url
  * @property string|null $new_feed_url
- * @property Location $location
+ * @property Location|null $location
  * @property string|null $location_name
  * @property string|null $location_geo
- * @property string|null $location_osm_id
+ * @property string|null $location_osm
  * @property string|null $payment_pointer
  * @property array|null $custom_rss
  * @property string $custom_rss_string
@@ -78,10 +78,10 @@ use RuntimeException;
 class Podcast extends Entity
 {
     protected string $link;
-    protected ?Actor $actor;
+    protected ?Actor $actor = null;
     protected Image $image;
-    protected string $description;
-    protected ?Category $category;
+    protected ?string $description = null;
+    protected ?Category $category = null;
 
     /**
      * @var Category[]
@@ -123,7 +123,7 @@ class Podcast extends Entity
      */
     protected $funding_platforms = [];
 
-    protected ?Location $location;
+    protected ?Location $location = null;
     protected string $custom_rss_string;
 
     /**
@@ -155,7 +155,7 @@ class Podcast extends Entity
         'new_feed_url' => '?string',
         'location_name' => '?string',
         'location_geo' => '?string',
-        'location_osm_id' => '?string',
+        'location_osm' => '?string',
         'payment_pointer' => '?string',
         'custom_rss' => '?json-array',
         'partner_id' => '?string',
@@ -331,17 +331,17 @@ class Podcast extends Entity
 
     public function getDescription(): string
     {
-        if ($this->description !== '') {
-            return $this->description;
+        if ($this->description === null) {
+            $this->description = trim(
+                (string) preg_replace(
+                    '~\s+~',
+                    ' ',
+                    strip_tags($this->attributes['description_html']),
+                ),
+            );
         }
 
-        return trim(
-            preg_replace(
-                '~\s+~',
-                ' ',
-                strip_tags($this->attributes['description_html']),
-            ),
-        );
+        return $this->description;
     }
 
     /**
@@ -451,28 +451,25 @@ class Podcast extends Entity
     /**
      * Saves the location name and fetches OpenStreetMap info
      */
-    public function setLocation(?string $newLocationName = null): static
+    public function setLocation(?Location $location = null): static
     {
-        if ($newLocationName === null) {
+        if ($location === null) {
             $this->attributes['location_name'] = null;
             $this->attributes['location_geo'] = null;
-            $this->attributes['location_osm_id'] = null;
-        }
-
-        helper('location');
+            $this->attributes['location_osm'] = null;
 
-        $oldLocationName = $this->attributes['location_name'];
+            return $this;
+        }
 
         if (
-            $oldLocationName === null ||
-            $oldLocationName !== $newLocationName
+            !isset($this->attributes['location_name']) ||
+            $this->attributes['location_name'] !== $location->name
         ) {
-            $this->attributes['location_name'] = $newLocationName;
+            $location->fetchOsmLocation();
 
-            if ($location = fetch_osm_location($newLocationName)) {
-                $this->attributes['location_geo'] = $location['geo'];
-                $this->attributes['location_osm_id'] = $location['osm_id'];
-            }
+            $this->attributes['location_name'] = $location->name;
+            $this->attributes['location_geo'] = $location->geo;
+            $this->attributes['location_osm'] = $location->osm;
         }
 
         return $this;
@@ -485,11 +482,11 @@ class Podcast extends Entity
         }
 
         if ($this->location === null) {
-            $this->location = new Location([
-                'name' => $this->location_name,
-                'geo' => $this->location_geo,
-                'osm_id' => $this->location_osm_id,
-            ]);
+            $this->location = new Location(
+                $this->location_name,
+                $this->location_geo,
+                $this->location_osm,
+            );
         }
 
         return $this->location;
diff --git a/app/Entities/User.php b/app/Entities/User.php
index e899ff8f1e..909799f100 100644
--- a/app/Entities/User.php
+++ b/app/Entities/User.php
@@ -27,9 +27,9 @@ use Myth\Auth\Entities\User as MythAuthUser;
 class User extends MythAuthUser
 {
     /**
-     * @var Podcast[]
+     * @var Podcast[]|null
      */
-    protected $podcasts = [];
+    protected ?array $podcasts = null;
 
     /**
      * Array of field names and the type of value to cast them as
diff --git a/app/Helpers/components_helper.php b/app/Helpers/components_helper.php
index 647bfe2031..172bfca118 100644
--- a/app/Helpers/components_helper.php
+++ b/app/Helpers/components_helper.php
@@ -7,6 +7,7 @@
  */
 
 use App\Entities\Location;
+use App\Entities\Person;
 use CodeIgniter\View\Table;
 use CodeIgniter\I18n\Time;
 
@@ -291,13 +292,11 @@ if (!function_exists('publication_button')) {
      * Publication button component
      *
      * Displays the appropriate publication button depending on the publication status.
-     *
-     * @param boolean   $publicationStatus the episode's publication status     *
      */
     function publication_button(
         int $podcastId,
         int $episodeId,
-        bool $publicationStatus
+        string $publicationStatus
     ): string {
         switch ($publicationStatus) {
             case 'not_published':
@@ -416,3 +415,59 @@ if (!function_exists('location_link')) {
 }
 
 // ------------------------------------------------------------------------
+
+if (!function_exists('person_list')) {
+    /**
+     * Returns list of persons images
+     *
+     * @param Person[] $persons
+     */
+    function person_list(array $persons, string $class = ''): string
+    {
+        if ($persons === []) {
+            return '';
+        }
+
+        $personList = "<div class='flex w-full space-x-2 overflow-y-auto {$class}'>";
+
+        foreach ($persons as $person) {
+            $personList .= anchor(
+                $person->information_url ?? '#',
+                "<img
+                    src='{$person->image->thumbnail_url}'
+                    alt='$person->full_name'
+                    class='object-cover w-12 h-12 rounded-full' />",
+                [
+                    'class' =>
+                        'flex-shrink-0 focus:outline-none focus:ring focus:ring-inset',
+                    'target' => '_blank',
+                    'rel' => 'noreferrer noopener',
+                    'title' =>
+                        '<strong>' .
+                        $person->full_name .
+                        '</strong>' .
+                        implode(
+                            array_map(function ($role) {
+                                return '<br />' .
+                                    lang(
+                                        'PersonsTaxonomy.persons.' .
+                                            $role->group .
+                                            '.roles.' .
+                                            $role->role .
+                                            '.label',
+                                    );
+                            }, $person->roles),
+                        ),
+                    'data-toggle' => 'tooltip',
+                    'data-placement' => 'bottom',
+                ],
+            );
+        }
+
+        $personList .= '</div>';
+
+        return $personList;
+    }
+}
+
+// ------------------------------------------------------------------------
diff --git a/app/Helpers/form_helper.php b/app/Helpers/form_helper.php
index 180efd5e43..2a8830086e 100644
--- a/app/Helpers/form_helper.php
+++ b/app/Helpers/form_helper.php
@@ -99,14 +99,14 @@ if (!function_exists('form_label')) {
     /**
      * Form Label Tag
      *
-     * @param string $label_text The text to appear onscreen
+     * @param string $text The text to appear onscreen
      * @param string $id         The id the label applies to
      * @param array<string, string>  $attributes Additional attributes
      * @param string  $hintText Hint text to add next to the label
      * @param boolean  $isOptional adds an optional text if true
      */
     function form_label(
-        string $label_text = '',
+        string $text = '',
         string $id = '',
         array $attributes = [],
         string $hintText = '',
@@ -124,19 +124,19 @@ if (!function_exists('form_label')) {
             }
         }
 
-        $label_content = $label_text;
+        $labelContent = $text;
         if ($isOptional) {
-            $label_content .=
+            $labelContent .=
                 '<small class="ml-1 lowercase">(' .
                 lang('Common.optional') .
                 ')</small>';
         }
 
         if ($hintText !== '') {
-            $label_content .= hint_tooltip($hintText, 'ml-1');
+            $labelContent .= hint_tooltip($hintText, 'ml-1');
         }
 
-        return $label . '>' . $label_content . '</label>';
+        return $label . '>' . $labelContent . '</label>';
     }
 }
 
diff --git a/app/Helpers/id3_helper.php b/app/Helpers/id3_helper.php
index b0739c6c73..d13239dda6 100644
--- a/app/Helpers/id3_helper.php
+++ b/app/Helpers/id3_helper.php
@@ -54,9 +54,9 @@ if (!function_exists('write_audio_file_tags')) {
         $APICdata = file_get_contents($cover->getRealPath());
 
         // TODO: variables used for podcast specific tags
-        // $podcast_url = $episode->podcast->link;
-        // $podcast_feed_url = $episode->podcast->feed_url;
-        // $episode_media_url = $episode->link;
+        // $podcastUrl = $episode->podcast->link;
+        // $podcastFeedUrl = $episode->podcast->feed_url;
+        // $episodeMediaUrl = $episode->link;
 
         // populate data array
         $TagData = [
@@ -74,7 +74,7 @@ if (!function_exists('write_audio_file_tags')) {
             ],
             'genre' => ['Podcast'],
             'comment' => [$episode->description],
-            'track_number' => [strval($episode->number)],
+            'track_number' => [(string) $episode->number],
             'copyright_message' => [$episode->podcast->copyright],
             'publisher' => [
                 empty($episode->podcast->publisher)
diff --git a/app/Helpers/location_helper.php b/app/Helpers/location_helper.php
deleted file mode 100644
index 9a91e8a1d5..0000000000
--- a/app/Helpers/location_helper.php
+++ /dev/null
@@ -1,62 +0,0 @@
-<?php
-
-/**
- * @copyright  2020 Podlibre
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-use Config\Services;
-
-if (!function_exists('fetch_osm_location')) {
-    /**
-     * Fetches places from Nominatim OpenStreetMap
-     *
-     * TODO: move this to Location object?
-     *
-     * @return array<string, string>|null
-     */
-    function fetch_osm_location(string $locationName): ?array
-    {
-        $osmObject = null;
-
-        try {
-            $client = Services::curlrequest();
-
-            $response = $client->request(
-                'GET',
-                'https://nominatim.openstreetmap.org/search.php?q=' .
-                    urlencode($locationName) .
-                    '&polygon_geojson=1&format=jsonv2',
-                [
-                    'headers' => [
-                        'User-Agent' => 'Castopod/' . CP_VERSION,
-                        'Accept' => 'application/json',
-                    ],
-                ],
-            );
-            $places = json_decode(
-                $response->getBody(),
-                true,
-                512,
-                JSON_THROW_ON_ERROR,
-            );
-
-            $osmObject = [
-                'geo' =>
-                    empty($places[0]['lat']) || empty($places[0]['lon'])
-                        ? null
-                        : "geo:{$places[0]['lat']},{$places[0]['lon']}",
-                'osm_id' => empty($places[0]['osm_type'])
-                    ? null
-                    : strtoupper(substr($places[0]['osm_type'], 0, 1)) .
-                        $places[0]['osm_id'],
-            ];
-        } catch (Exception $exception) {
-            //If things go wrong the show must go on
-            log_message('critical', $exception);
-        }
-
-        return $osmObject;
-    }
-}
diff --git a/app/Helpers/misc_helper.php b/app/Helpers/misc_helper.php
index d8d4e8a29e..d90e8fa5f3 100644
--- a/app/Helpers/misc_helper.php
+++ b/app/Helpers/misc_helper.php
@@ -29,7 +29,7 @@ if (!function_exists('slugify')) {
         // replace non letter or digits by -
         $text = preg_replace('~[^\pL\d]+~u', '-', $text);
 
-        $unwanted_array = [
+        $unwanted = [
             'Å ' => 'S',
             'Å¡' => 's',
             'Đ' => 'Dj',
@@ -107,7 +107,7 @@ if (!function_exists('slugify')) {
             '/' => '-',
             ' ' => '-',
         ];
-        $text = strtr($text, $unwanted_array);
+        $text = strtr($text, $unwanted);
 
         // transliterate
         $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php
index 41adc5460d..8fed1cd0ad 100644
--- a/app/Helpers/rss_helper.php
+++ b/app/Helpers/rss_helper.php
@@ -23,31 +23,31 @@ if (!function_exists('get_rss_feed')) {
     {
         $episodes = $podcast->episodes;
 
-        $itunes_namespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
+        $itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
 
-        $podcast_namespace =
+        $podcastNamespace =
             'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md';
 
         $rss = new SimpleRSSElement(
-            "<?xml version='1.0' encoding='utf-8'?><rss version='2.0' xmlns:itunes='{$itunes_namespace}' xmlns:podcast='{$podcast_namespace}' xmlns:content='http://purl.org/rss/1.0/modules/content/'></rss>",
+            "<?xml version='1.0' encoding='utf-8'?><rss version='2.0' xmlns:itunes='{$itunesNamespace}' xmlns:podcast='{$podcastNamespace}' xmlns:content='http://purl.org/rss/1.0/modules/content/'></rss>",
         );
 
         $channel = $rss->addChild('channel');
 
-        $atom_link = $channel->addChild(
+        $atomLink = $channel->addChild(
             'atom:link',
             null,
             'http://www.w3.org/2005/Atom',
         );
-        $atom_link->addAttribute('href', $podcast->feed_url);
-        $atom_link->addAttribute('rel', 'self');
-        $atom_link->addAttribute('type', 'application/rss+xml');
+        $atomLink->addAttribute('href', $podcast->feed_url);
+        $atomLink->addAttribute('rel', 'self');
+        $atomLink->addAttribute('type', 'application/rss+xml');
 
         if ($podcast->new_feed_url !== null) {
             $channel->addChild(
                 'new-feed-url',
                 $podcast->new_feed_url,
-                $itunes_namespace,
+                $itunesNamespace,
             );
         }
 
@@ -65,33 +65,30 @@ if (!function_exists('get_rss_feed')) {
         $channel->addChild('title', $podcast->title);
         $channel->addChildWithCDATA('description', $podcast->description_html);
 
-        $itunes_image = $channel->addChild('image', null, $itunes_namespace);
+        $itunesImage = $channel->addChild('image', null, $itunesNamespace);
 
         // FIXME: This should be downsized to 1400x1400
-        $itunes_image->addAttribute('href', $podcast->image->url);
+        $itunesImage->addAttribute('href', $podcast->image->url);
 
         $channel->addChild('language', $podcast->language_code);
         if ($podcast->location !== null) {
             $locationElement = $channel->addChild(
                 'location',
                 htmlspecialchars($podcast->location->name),
-                $podcast_namespace,
+                $podcastNamespace,
             );
             if ($podcast->location->geo !== null) {
                 $locationElement->addAttribute('geo', $podcast->location->geo);
             }
-            if ($podcast->location->osm_id !== null) {
-                $locationElement->addAttribute(
-                    'osm',
-                    $podcast->location->osm_id,
-                );
+            if ($podcast->location->osm !== null) {
+                $locationElement->addAttribute('osm', $podcast->location->osm);
             }
         }
         if ($podcast->payment_pointer !== null) {
             $valueElement = $channel->addChild(
                 'value',
                 null,
-                $podcast_namespace,
+                $podcastNamespace,
             );
             $valueElement->addAttribute('type', 'webmonetization');
             $valueElement->addAttribute('method', '');
@@ -99,7 +96,7 @@ if (!function_exists('get_rss_feed')) {
             $recipientElement = $valueElement->addChild(
                 'valueRecipient',
                 null,
-                $podcast_namespace,
+                $podcastNamespace,
             );
             $recipientElement->addAttribute('name', $podcast->owner_name);
             $recipientElement->addAttribute('type', 'ILP');
@@ -113,14 +110,14 @@ if (!function_exists('get_rss_feed')) {
             ->addChild(
                 'locked',
                 $podcast->is_locked ? 'yes' : 'no',
-                $podcast_namespace,
+                $podcastNamespace,
             )
             ->addAttribute('owner', $podcast->owner_email);
         if ($podcast->imported_feed_url !== null) {
             $channel->addChild(
                 'previousUrl',
                 $podcast->imported_feed_url,
-                $podcast_namespace,
+                $podcastNamespace,
             );
         }
 
@@ -128,7 +125,7 @@ if (!function_exists('get_rss_feed')) {
             $podcastingPlatformElement = $channel->addChild(
                 'id',
                 null,
-                $podcast_namespace,
+                $podcastNamespace,
             );
             $podcastingPlatformElement->addAttribute(
                 'platform',
@@ -152,7 +149,7 @@ if (!function_exists('get_rss_feed')) {
             $socialPlatformElement = $channel->addChild(
                 'social',
                 $socialPlatform->link_content,
-                $podcast_namespace,
+                $podcastNamespace,
             );
             $socialPlatformElement->addAttribute(
                 'platform',
@@ -170,7 +167,7 @@ if (!function_exists('get_rss_feed')) {
             $fundingPlatformElement = $channel->addChild(
                 'funding',
                 $fundingPlatform->link_content,
-                $podcast_namespace,
+                $podcastNamespace,
             );
             $fundingPlatformElement->addAttribute(
                 'platform',
@@ -188,7 +185,7 @@ if (!function_exists('get_rss_feed')) {
             $podcastPersonElement = $channel->addChild(
                 'person',
                 htmlspecialchars($podcastPerson->full_name),
-                $podcast_namespace,
+                $podcastNamespace,
             );
 
             if (
@@ -242,29 +239,29 @@ if (!function_exists('get_rss_feed')) {
         $channel->addChild(
             'explicit',
             $podcast->parental_advisory === 'explicit' ? 'true' : 'false',
-            $itunes_namespace,
+            $itunesNamespace,
         );
 
         $channel->addChild(
             'author',
             $podcast->publisher ? $podcast->publisher : $podcast->owner_name,
-            $itunes_namespace,
+            $itunesNamespace,
         );
         $channel->addChild('link', $podcast->link);
 
-        $owner = $channel->addChild('owner', null, $itunes_namespace);
+        $owner = $channel->addChild('owner', null, $itunesNamespace);
 
-        $owner->addChild('name', $podcast->owner_name, $itunes_namespace);
+        $owner->addChild('name', $podcast->owner_name, $itunesNamespace);
 
-        $owner->addChild('email', $podcast->owner_email, $itunes_namespace);
+        $owner->addChild('email', $podcast->owner_email, $itunesNamespace);
 
-        $channel->addChild('type', $podcast->type, $itunes_namespace);
+        $channel->addChild('type', $podcast->type, $itunesNamespace);
         $podcast->copyright &&
             $channel->addChild('copyright', $podcast->copyright);
         $podcast->is_blocked &&
-            $channel->addChild('block', 'Yes', $itunes_namespace);
+            $channel->addChild('block', 'Yes', $itunesNamespace);
         $podcast->is_completed &&
-            $channel->addChild('complete', 'Yes', $itunes_namespace);
+            $channel->addChild('complete', 'Yes', $itunesNamespace);
 
         $image = $channel->addChild('image');
         $image->addChild('url', $podcast->image->feed_url);
@@ -304,7 +301,7 @@ if (!function_exists('get_rss_feed')) {
                 $locationElement = $item->addChild(
                     'location',
                     htmlspecialchars($episode->location->name),
-                    $podcast_namespace,
+                    $podcastNamespace,
                 );
                 if ($episode->location->geo !== null) {
                     $locationElement->addAttribute(
@@ -312,10 +309,10 @@ if (!function_exists('get_rss_feed')) {
                         $episode->location->geo,
                     );
                 }
-                if ($episode->location->osm_id !== null) {
+                if ($episode->location->osm !== null) {
                     $locationElement->addAttribute(
                         'osm',
-                        $episode->location->osm_id,
+                        $episode->location->osm,
                     );
                 }
             }
@@ -326,15 +323,15 @@ if (!function_exists('get_rss_feed')) {
             $item->addChild(
                 'duration',
                 $episode->audio_file_duration,
-                $itunes_namespace,
+                $itunesNamespace,
             );
             $item->addChild('link', $episode->link);
-            $episode_itunes_image = $item->addChild(
+            $episodeItunesImage = $item->addChild(
                 'image',
                 null,
-                $itunes_namespace,
+                $itunesNamespace,
             );
-            $episode_itunes_image->addAttribute(
+            $episodeItunesImage->addAttribute(
                 'href',
                 $episode->image->feed_url,
             );
@@ -345,24 +342,24 @@ if (!function_exists('get_rss_feed')) {
                     $episode->parental_advisory === 'explicit'
                         ? 'true'
                         : 'false',
-                    $itunes_namespace,
+                    $itunesNamespace,
                 );
 
             $episode->number &&
-                $item->addChild('episode', $episode->number, $itunes_namespace);
+                $item->addChild('episode', $episode->number, $itunesNamespace);
             $episode->season_number &&
                 $item->addChild(
                     'season',
                     $episode->season_number,
-                    $itunes_namespace,
+                    $itunesNamespace,
                 );
-            $item->addChild('episodeType', $episode->type, $itunes_namespace);
+            $item->addChild('episodeType', $episode->type, $itunesNamespace);
 
             if ($episode->transcript_file_url) {
                 $transcriptElement = $item->addChild(
                     'transcript',
                     null,
-                    $podcast_namespace,
+                    $podcastNamespace,
                 );
                 $transcriptElement->addAttribute(
                     'url',
@@ -387,7 +384,7 @@ if (!function_exists('get_rss_feed')) {
                 $chaptersElement = $item->addChild(
                     'chapters',
                     null,
-                    $podcast_namespace,
+                    $podcastNamespace,
                 );
                 $chaptersElement->addAttribute(
                     'url',
@@ -403,7 +400,7 @@ if (!function_exists('get_rss_feed')) {
                 $soundbiteElement = $item->addChild(
                     'soundbite',
                     empty($soundbite->label) ? null : $soundbite->label,
-                    $podcast_namespace,
+                    $podcastNamespace,
                 );
                 $soundbiteElement->addAttribute(
                     'start_time',
@@ -419,7 +416,7 @@ if (!function_exists('get_rss_feed')) {
                 $episodePersonElement = $item->addChild(
                     'person',
                     htmlspecialchars($episodePerson->full_name),
-                    $podcast_namespace,
+                    $podcastNamespace,
                 );
                 if (
                     !empty($episodePerson->role) &&
@@ -461,7 +458,7 @@ if (!function_exists('get_rss_feed')) {
             }
 
             $episode->is_blocked &&
-                $item->addChild('block', 'Yes', $itunes_namespace);
+                $item->addChild('block', 'Yes', $itunesNamespace);
 
             if (!empty($episode->custom_rss)) {
                 array_to_rss(
@@ -483,10 +480,10 @@ if (!function_exists('add_category_tag')) {
      */
     function add_category_tag(SimpleXMLElement $node, Category $category): void
     {
-        $itunes_namespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
+        $itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
 
-        $itunes_category = $node->addChild('category', '', $itunes_namespace);
-        $itunes_category->addAttribute(
+        $itunesCategory = $node->addChild('category', '', $itunesNamespace);
+        $itunesCategory->addAttribute(
             'text',
             $category->parent !== null
                 ? $category->parent->apple_category
@@ -494,12 +491,12 @@ if (!function_exists('add_category_tag')) {
         );
 
         if ($category->parent !== null) {
-            $itunes_category_child = $itunes_category->addChild(
+            $itunesCategoryChild = $itunesCategory->addChild(
                 'category',
                 '',
-                $itunes_namespace,
+                $itunesNamespace,
             );
-            $itunes_category_child->addAttribute(
+            $itunesCategoryChild->addAttribute(
                 'text',
                 $category->apple_category,
             );
diff --git a/app/Helpers/svg_helper.php b/app/Helpers/svg_helper.php
index b612016921..6078aa771f 100644
--- a/app/Helpers/svg_helper.php
+++ b/app/Helpers/svg_helper.php
@@ -16,16 +16,16 @@ if (!function_exists('icon')) {
      */
     function icon(string $name, string $class = ''): string
     {
-        $svg_contents = file_get_contents('assets/icons/' . $name . '.svg');
+        $svgContents = file_get_contents('assets/icons/' . $name . '.svg');
         if ($class !== '') {
-            $svg_contents = str_replace(
+            $svgContents = str_replace(
                 '<svg',
                 '<svg class="' . $class . '"',
-                $svg_contents,
+                $svgContents,
             );
         }
 
-        return $svg_contents;
+        return $svgContents;
     }
 }
 
@@ -39,14 +39,14 @@ if (!function_exists('svg')) {
      */
     function svg(string $name, ?string $class = null): string
     {
-        $svg_contents = file_get_contents('assets/images/' . $name . '.svg');
+        $svgContents = file_get_contents('assets/images/' . $name . '.svg');
         if ($class) {
-            $svg_contents = str_replace(
+            $svgContents = str_replace(
                 '<svg',
                 '<svg class="' . $class . '"',
-                $svg_contents,
+                $svgContents,
             );
         }
-        return $svg_contents;
+        return $svgContents;
     }
 }
diff --git a/app/Helpers/url_helper.php b/app/Helpers/url_helper.php
index f92d34b0b8..d58614bb5b 100644
--- a/app/Helpers/url_helper.php
+++ b/app/Helpers/url_helper.php
@@ -27,22 +27,6 @@ if (!function_exists('host_url')) {
     }
 }
 
-if (!function_exists('current_season_url')) {
-    /**
-     * Return the podcast URL with season number to use in views
-     */
-    function current_season_url(): string
-    {
-        $season_query_string = '';
-        if (isset($_GET['season'])) {
-            $season_query_string = '?season=' . $_GET['season'];
-        } elseif (isset($_GET['year'])) {
-            $season_query_string = '?year=' . $_GET['year'];
-        }
-        return current_url() . $season_query_string;
-    }
-}
-
 //--------------------------------------------------------------------
 
 if (!function_exists('extract_params_from_episode_uri')) {
diff --git a/app/Libraries/ActivityPub/ActivityRequest.php b/app/Libraries/ActivityPub/ActivityRequest.php
index f2c88e09da..a79559763f 100644
--- a/app/Libraries/ActivityPub/ActivityRequest.php
+++ b/app/Libraries/ActivityPub/ActivityRequest.php
@@ -85,7 +85,7 @@ class ActivityRequest
         $date = Time::now('GMT')->format('D, d M Y H:i:s T');
         $digest = 'SHA-256=' . base64_encode($this->getBodyDigest());
         $contentType = $this->options['headers']['Content-Type'];
-        $contentLength = strval(strlen($this->request->getBody()));
+        $contentLength = (string) strlen($this->request->getBody());
         $userAgent = 'Castopod';
 
         $plainText = "(request-target): post {$path}\nhost: {$host}\ndate: {$date}\ndigest: {$digest}\ncontent-type: {$contentType}\ncontent-length: {$contentLength}\nuser-agent: {$userAgent}";
diff --git a/app/Libraries/ActivityPub/Helpers/activitypub_helper.php b/app/Libraries/ActivityPub/Helpers/activitypub_helper.php
index 53df425dfc..abb73622c8 100644
--- a/app/Libraries/ActivityPub/Helpers/activitypub_helper.php
+++ b/app/Libraries/ActivityPub/Helpers/activitypub_helper.php
@@ -182,7 +182,7 @@ if (!function_exists('create_preview_card_from_url')) {
 
             // Check that, at least, the url and title are set
             if ($media->url && $media->title) {
-                $preview_card = new PreviewCard([
+                $newPreviewCard = new PreviewCard([
                     'url' => (string) $url,
                     'title' => $media->title,
                     'description' => $media->description,
@@ -199,15 +199,15 @@ if (!function_exists('create_preview_card_from_url')) {
 
                 if (
                     !($newPreviewCardId = model('PreviewCardModel')->insert(
-                        $preview_card,
+                        $newPreviewCard,
                         true,
                     ))
                 ) {
                     return null;
                 }
 
-                $preview_card->id = $newPreviewCardId;
-                return $preview_card;
+                $newPreviewCard->id = $newPreviewCardId;
+                return $newPreviewCard;
             }
         }
 
diff --git a/app/Libraries/ActivityPub/HttpSignature.php b/app/Libraries/ActivityPub/HttpSignature.php
index f62e2fe690..46ab35ae05 100644
--- a/app/Libraries/ActivityPub/HttpSignature.php
+++ b/app/Libraries/ActivityPub/HttpSignature.php
@@ -45,7 +45,7 @@ class HttpSignature
 
     public function __construct(IncomingRequest $request = null)
     {
-        if (is_null($request)) {
+        if ($request === null) {
             $request = Services::request();
         }
 
diff --git a/app/Libraries/Analytics/Controllers/AnalyticsController.php b/app/Libraries/Analytics/Controllers/AnalyticsController.php
index 9f0ea4ace1..40c0478338 100644
--- a/app/Libraries/Analytics/Controllers/AnalyticsController.php
+++ b/app/Libraries/Analytics/Controllers/AnalyticsController.php
@@ -39,18 +39,21 @@ class AnalyticsController extends Controller
         );
     }
 
-    public function getData(int $podcastId, int $episodeId): ResponseInterface
-    {
-        $analytics_model = new $this->className();
+    public function getData(
+        int $podcastId,
+        ?int $episodeId = null
+    ): ResponseInterface {
+        $analyticsModel = new $this->className();
         $methodName = $this->methodName;
-        if ($episodeId !== 0) {
+
+        if ($episodeId === null) {
             return $this->response->setJSON(
-                $analytics_model->$methodName($podcastId, $episodeId),
+                $analyticsModel->$methodName($podcastId),
             );
         }
 
         return $this->response->setJSON(
-            $analytics_model->$methodName($podcastId),
+            $analyticsModel->$methodName($podcastId, $episodeId),
         );
     }
 }
diff --git a/app/Libraries/SimpleRSSElement.php b/app/Libraries/SimpleRSSElement.php
index 1bb2517b64..aa2b45fbd0 100644
--- a/app/Libraries/SimpleRSSElement.php
+++ b/app/Libraries/SimpleRSSElement.php
@@ -26,15 +26,15 @@ class SimpleRSSElement extends SimpleXMLElement
         string $value = '',
         ?string $namespace = null
     ) {
-        $new_child = parent::addChild($name, '', $namespace);
+        $newChild = parent::addChild($name, '', $namespace);
 
-        if ($new_child !== null) {
-            $node = dom_import_simplexml($new_child);
+        if ($newChild !== null) {
+            $node = dom_import_simplexml($newChild);
             $no = $node->ownerDocument;
             $node->appendChild($no->createCDATASection($value));
         }
 
-        return $new_child;
+        return $newChild;
     }
 
     /**
@@ -49,14 +49,14 @@ class SimpleRSSElement extends SimpleXMLElement
      */
     public function addChild($name, $value = null, $namespace = null)
     {
-        $new_child = parent::addChild($name, '', $namespace);
+        $newChild = parent::addChild($name, '', $namespace);
 
-        if ($new_child !== null) {
-            $node = dom_import_simplexml($new_child);
+        if ($newChild !== null) {
+            $node = dom_import_simplexml($newChild);
             $no = $node->ownerDocument;
             $node->appendChild($no->createTextNode(esc($value)));
         }
 
-        return $new_child;
+        return $newChild;
     }
 }
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index c39e741719..cde7b10271 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -83,7 +83,7 @@ class EpisodeModel extends Model
         'is_blocked',
         'location_name',
         'location_geo',
-        'location_osm_id',
+        'location_osm',
         'custom_rss',
         'favourites_total',
         'reblogs_total',
diff --git a/app/Models/PersonModel.php b/app/Models/PersonModel.php
index ae861561d7..d7ce0be846 100644
--- a/app/Models/PersonModel.php
+++ b/app/Models/PersonModel.php
@@ -19,6 +19,7 @@ class PersonModel extends Model
      * @var string
      */
     protected $table = 'persons';
+
     /**
      * @var string
      */
@@ -98,6 +99,90 @@ class PersonModel extends Model
         return $this->where('full_name', $fullName)->first();
     }
 
+    /**
+     * @return stdClass[]
+     */
+    public function getPersonRoles(int $personId, int $podcastId, ?int $episodeId): array {
+        if ($episodeId) {
+            $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_person#{$personId}_roles";
+
+            if (!($found = cache($cacheName))) {
+                $found = $this
+                    ->select('episodes_persons.person_group as group, episodes_persons.person_role as role')
+                    ->join('episodes_persons', 'persons.id = episodes_persons.person_id')
+                    ->where('persons.id', $personId)
+                    ->where('episodes_persons.episode_id', $episodeId)
+                    ->get()
+                    ->getResultObject();
+            }
+        } else {
+            $cacheName = "podcast#{$podcastId}_person#{$personId}_roles";
+
+            if (!($found = cache($cacheName))) {
+                $found = $this
+                    ->select('podcasts_persons.person_group as group, podcasts_persons.person_role as role')
+                    ->join('podcasts_persons', 'persons.id = podcasts_persons.person_id')
+                    ->where('persons.id', $personId)
+                    ->where('podcasts_persons.podcast_id', $podcastId)
+                    ->get()
+                    ->getResultObject();
+            }
+        }
+
+        return $found;
+    }
+
+    /**
+     * @return array<string, string> 
+     */
+    public function getPersonOptions(): array
+    {
+        $options = [];
+
+        if (!($options = cache('person_options'))) {
+            $options = array_reduce(
+                $this->select('`id`, `full_name`')
+                    ->orderBy('`full_name`', 'ASC')
+                    ->findAll(),
+                function ($result, $person) {
+                    $result[$person->id] = $person->full_name;
+                    return $result;
+                },
+                [],
+            );
+            cache()->save('person_options', $options, DECADE);
+        }
+
+        return $options;
+    }
+
+    /**
+     * @return array<string, string> 
+     */
+    public function getTaxonomyOptions(): array
+    {
+        $options = [];
+        $locale = service('request')->getLocale();
+        $cacheName = "taxonomy_options_{$locale}";
+
+        /** @var array<string, array> */
+        $personsTaxonomy = lang('PersonsTaxonomy.persons');
+
+        if (!($options = cache($cacheName))) {
+            foreach ($personsTaxonomy as $group_key => $group) {
+                foreach ($group['roles'] as $role_key => $role) {
+                    $options[
+                        "{$group_key},{$role_key}"
+                    ] = "{$group['label']}  ›  {$role['label']}";
+                }
+            }
+
+            cache()->save($cacheName, $options, DECADE);
+        }
+
+        return $options;
+    }
+
     public function addPerson(
         string $fullName,
         ?string $informationUrl,
@@ -122,12 +207,12 @@ class PersonModel extends Model
     {
         $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_persons";
         if (!($found = cache($cacheName))) {
-            $found = $this->db
-                ->table('episodes_persons')
-                ->select('episodes_persons.*')
-                ->where('episode_id', $episodeId)
-                ->join('persons', 'person_id=persons.id')
-                ->orderby('full_name')
+            $found = $this
+                ->select('persons.*, episodes_persons.podcast_id, episodes_persons.episode_id')
+                ->distinct()
+                ->join('episodes_persons', 'persons.id = episodes_persons.person_id')
+                ->where('episodes_persons.episode_id', $episodeId)
+                ->orderby('persons.full_name')
                 ->findAll();
 
             cache()->save($cacheName, $found, DECADE);
@@ -143,12 +228,12 @@ class PersonModel extends Model
     {
         $cacheName = "podcast#{$podcastId}_persons";
         if (!($found = cache($cacheName))) {
-            $found = $this->db
-                ->table('podcasts_persons')
-                ->select('podcasts_persons.*')
-                ->where('podcast_id', $podcastId)
-                ->join('persons', 'person_id=persons.id')
-                ->orderby('full_name')
+            $found = $this
+                ->select('persons.*, podcasts_persons.podcast_id as podcast_id')
+                ->distinct()
+                ->join('podcasts_persons', 'persons.id=podcasts_persons.person_id')
+                ->where('podcasts_persons.podcast_id', $podcastId)
+                ->orderby('persons.full_name')
                 ->findAll();
 
             cache()->save($cacheName, $found, DECADE);
@@ -161,29 +246,29 @@ class PersonModel extends Model
         int $podcastId,
         int $episodeId,
         int $personId,
-        string $group,
-        string $role
+        string $groupSlug,
+        string $roleSlug
     ): int|bool {
         return $this->db->table('episodes_persons')->insert([
             'podcast_id' => $podcastId,
             'episode_id' => $episodeId,
             'person_id' => $personId,
-            'person_group' => $group,
-            'person_role' => $role,
+            'person_group' => $groupSlug,
+            'person_role' => $roleSlug,
         ]);
     }
 
     public function addPodcastPerson(
         int $podcastId,
         int $personId,
-        string $group,
-        string $role
+        string $groupSlug,
+        string $roleSlug
     ): int|bool {
         return $this->db->table('podcasts_persons')->insert([
             'podcast_id' => $podcastId,
             'person_id' => $personId,
-            'person_group' => $group,
-            'person_role' => $role,
+            'person_group' => $groupSlug,
+            'person_role' => $roleSlug,
         ]);
     }
 
@@ -198,34 +283,36 @@ class PersonModel extends Model
     public function addPodcastPersons(
         int $podcastId,
         array $persons = [],
-        array $groupsRoles = []
+        array $roles = []
     ): int|bool {
         if ($persons === []) {
             return 0;
         }
 
-        $this->clearCache(['podcast_id' => $podcastId]);
+        cache()->delete("podcast#{$podcastId}_persons");
+        (new PodcastModel())->clearCache(['id' => $podcastId]);
+
         $data = [];
         foreach ($persons as $person) {
-            if ($groupsRoles === []) {
+            if ($roles === []) {
                 $data[] = [
                     'podcast_id' => $podcastId,
                     'person_id' => $person,
                 ];
             }
 
-            foreach ($groupsRoles as $group_role) {
-                $group_role = explode(',', $group_role);
+            foreach ($roles as $role) {
+                $groupRole = explode(',', $role);
                 $data[] = [
                     'podcast_id' => $podcastId,
                     'person_id' => $person,
-                    'person_group' => $group_role[0],
-                    'person_role' => $group_role[1],
+                    'person_group' => $groupRole[0],
+                    'person_role' => $groupRole[1],
                 ];
             }
         }
 
-        return $this->insertBatch($data);
+        return $this->db->table('podcasts_persons')->insertBatch($data);
     }
 
     /**
@@ -233,11 +320,11 @@ class PersonModel extends Model
      *
      * @return BaseResult|bool Number of rows inserted or FALSE on failure
      */
-    public function removePodcastPersons(int $podcastId, int $personId): BaseResult|bool
+    public function removePersonFromPodcast(int $podcastId, int $personId): BaseResult|bool
     {
-        return $this->delete([
-            'id' => $personId,
+        return $this->db->table('podcasts_persons')->delete([
             'podcast_id' => $podcastId,
+            'person_id' => $personId,
         ]);
     }
 
@@ -245,7 +332,7 @@ class PersonModel extends Model
      * Add persons to episode
      *
      * @param int[] $personIds
-     * @param string[] $groups_roles
+     * @param string[] $groupsRoles
      * 
      * @return bool|int Number of rows inserted or FALSE on failure
      */
@@ -253,24 +340,22 @@ class PersonModel extends Model
         int $podcastId,
         int $episodeId,
         array $personIds,
-        array $groups_roles
+        array $groupsRoles
     ): bool|int {
         if (!empty($personIds)) {
-            $this->clearCache([
-                'episode_id' => $episodeId,
-            ]);
+            (new EpisodeModel())->clearCache(['id' => $episodeId]);
 
             $data = [];
             foreach ($personIds as $personId) {
-                if ($groups_roles !== []) {
-                    foreach ($groups_roles as $group_role) {
-                        $group_role = explode(',', $group_role);
+                if ($groupsRoles !== []) {
+                    foreach ($groupsRoles as $groupRole) {
+                        $groupRole = explode(',', $groupRole);
                         $data[] = [
                             'podcast_id' => $podcastId,
                             'episode_id' => $episodeId,
                             'person_id' => $personId,
-                            'person_group' => $group_role[0],
-                            'person_role' => $group_role[1],
+                            'person_group' => $groupRole[0],
+                            'person_role' => $groupRole[1],
                         ];
                     }
                 } else {
@@ -281,7 +366,7 @@ class PersonModel extends Model
                     ];
                 }
             }
-            return $this->insertBatch($data);
+            return $this->db->table('episodes_persons')->insertBatch($data);
         }
         return 0;
     }
@@ -289,69 +374,18 @@ class PersonModel extends Model
     /**
      * @return BaseResult|bool
      */
-    public function removeEpisodePersons(
+    public function removePersonFromEpisode(
         int $podcastId,
         int $episodeId,
         int $personId
     ): BaseResult|bool {
-        return $this->delete([
+        return $this->db->table('episodes_persons')->delete([
             'podcast_id' => $podcastId,
             'episode_id' => $episodeId,
-            'id' => $personId,
+            'person_id' => $personId,
         ]);
     }
 
-    /**
-     * @return array<string, string> 
-     */
-    public function getPersonOptions(): array
-    {
-        $options = [];
-
-        if (!($options = cache('person_options'))) {
-            $options = array_reduce(
-                $this->select('`id`, `full_name`')
-                    ->orderBy('`full_name`', 'ASC')
-                    ->findAll(),
-                function ($result, $person) {
-                    $result[$person->id] = $person->full_name;
-                    return $result;
-                },
-                [],
-            );
-            cache()->save('person_options', $options, DECADE);
-        }
-
-        return $options;
-    }
-
-    /**
-     * @return array<string, string> 
-     */
-    public function getTaxonomyOptions(): array
-    {
-        $options = [];
-        $locale = service('request')->getLocale();
-        $cacheName = "taxonomy_options_{$locale}";
-
-        /** @var array<string, array> */
-        $personsTaxonomy = lang('PersonsTaxonomy.persons');
-
-        if (!($options = cache($cacheName))) {
-            foreach ($personsTaxonomy as $group_key => $group) {
-                foreach ($group['roles'] as $role_key => $role) {
-                    $options[
-                        "{$group_key},{$role_key}"
-                    ] = "{$group['label']}  â–¸  {$role['label']}";
-                }
-            }
-
-            cache()->save($cacheName, $options, DECADE);
-        }
-
-        return $options;
-    }
-
     /**
      * @param mixed[] $data
      * 
@@ -359,12 +393,10 @@ class PersonModel extends Model
      */
     protected function clearCache(array $data): array
     {
-        $person = (new PersonModel())->find(
-            is_array($data['id']) ? $data['id'][0] : $data['id'],
-        );
+        $personId = is_array($data['id']) ? $data['id']['id'] : $data['id'];
 
         cache()->delete('person_options');
-        cache()->delete("person#{$person->id}");
+        cache()->delete("person#{$personId}");
 
         // clear cache for every credits page
         cache()->deleteMatching('page_credits_*');
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index daf491beb4..dddcc5e095 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -53,7 +53,7 @@ class PodcastModel extends Model
         'is_locked',
         'location_name',
         'location_geo',
-        'location_osm_id',
+        'location_osm',
         'payment_pointer',
         'custom_rss',
         'partner_id',
@@ -218,11 +218,11 @@ class PodcastModel extends Model
             ->delete();
     }
 
-    public function getContributorGroupId(int $userId, int $podcastId): int|false
+    public function getContributorGroupId(int $userId, int|string $podcastId): int|false
     {
         if (!is_numeric($podcastId)) {
             // identifier is the podcast name, request must be a join
-            $user_podcast = $this->db
+            $userPodcast = $this->db
                 ->table('podcasts_users')
                 ->select('group_id, user_id')
                 ->join('podcasts', 'podcasts.id = podcasts_users.podcast_id')
@@ -233,7 +233,7 @@ class PodcastModel extends Model
                 ->get()
                 ->getResultObject();
         } else {
-            $user_podcast = $this->db
+            $userPodcast = $this->db
                 ->table('podcasts_users')
                 ->select('group_id')
                 ->where([
@@ -244,8 +244,8 @@ class PodcastModel extends Model
                 ->getResultObject();
         }
 
-        return count($user_podcast) > 0
-            ? $user_podcast[0]->group_id
+        return count($userPodcast) > 0
+            ? $userPodcast[0]->group_id
             : false;
     }
 
@@ -446,7 +446,7 @@ class PodcastModel extends Model
      *
      * @return mixed[]
      */
-    protected function clearCache(array $data): array
+    public function clearCache(array $data): array
     {
         $podcast = (new PodcastModel())->getPodcastById(
             is_array($data['id']) ? $data['id'][0] : $data['id'],
diff --git a/app/Views/admin/episode/person.php b/app/Views/admin/episode/persons.php
similarity index 58%
rename from app/Views/admin/episode/person.php
rename to app/Views/admin/episode/persons.php
index 03a828f172..08d681de13 100644
--- a/app/Views/admin/episode/person.php
+++ b/app/Views/admin/episode/persons.php
@@ -5,7 +5,7 @@
 <?= $this->endSection() ?>
 
 <?= $this->section('pageTitle') ?>
-<?= lang('Person.episode_form.title') ?> (<?= count($episodePersons) ?>)
+<?= lang('Person.episode_form.title') ?> (<?= count($episode->persons) ?>)
 <?= $this->endSection() ?>
 
 <?= $this->section('headerRight') ?>
@@ -25,7 +25,6 @@
 ]) ?>
 <?= csrf_field() ?>
 
-<?php if ($episodePersons): ?>
 
 <?= form_section(
     lang('Person.episode_form.manage_section_title'),
@@ -37,42 +36,45 @@
     [
         [
             'header' => lang('Person.episode_form.person'),
-            'cell' => function ($episodePerson) {
+            'cell' => function ($person) {
                 return '<div class="flex">' .
                     '<a href="' .
-                    route_to('person-view', $episodePerson->person->id) .
-                    "\"><img src=\"{$episodePerson->person->image->thumbnail_url}\" alt=\"{$episodePerson->person->full_name}\" class=\"object-cover w-16 h-16 rounded-full\" /></a>" .
+                    route_to('person-view', $person->id) .
+                    "\"><img src=\"{$person->image->thumbnail_url}\" alt=\"{$person->full_name}\" class=\"object-cover w-16 h-16 rounded-full\" /></a>" .
                     '<div class="flex flex-col ml-3">' .
-                    $episodePerson->person->full_name .
-                    ($episodePerson->person_group && $episodePerson->person_role
-                        ? '<span class="text-sm text-gray-600">' .
-                            lang(
-                                "PersonsTaxonomy.persons.{$episodePerson->person_group}.label",
-                            ) .
-                            ' â–¸ ' .
-                            lang(
-                                "PersonsTaxonomy.persons.{$episodePerson->person_group}.roles.{$episodePerson->person_role}.label",
-                            ) .
-                            '</span>'
-                        : '') .
-                    (empty($episodePerson->person->information_url)
+                    $person->full_name .
+                    implode(
+                        '',
+                        array_map(function ($role) {
+                            return '<span class="text-sm text-gray-600">' .
+                                lang(
+                                    "PersonsTaxonomy.persons.{$role->group}.label",
+                                ) .
+                                ' › ' .
+                                lang(
+                                    "PersonsTaxonomy.persons.{$role->group}.roles.{$role->role}.label",
+                                ) .
+                                '</span>';
+                        }, $person->roles),
+                    ) .
+                    ($person->information_url === null
                         ? ''
-                        : "<a href=\"{$episodePerson->person->information_url}\" target=\"_blank\" rel=\"noreferrer noopener\" class=\"text-sm text-blue-800 hover:underline\">" .
-                            $episodePerson->person->information_url .
+                        : "<a href=\"{$person->information_url}\" target=\"_blank\" rel=\"noreferrer noopener\" class=\"text-sm text-blue-800 hover:underline\">" .
+                            $person->information_url .
                             '</a>') .
                     '</div></div>';
             },
         ],
         [
             'header' => lang('Common.actions'),
-            'cell' => function ($episodePerson): string {
+            'cell' => function ($person): string {
                 return button(
                     lang('Person.episode_form.remove'),
                     route_to(
                         'episode-person-remove',
-                        $episodePerson->podcast_id,
-                        $episodePerson->episode_id,
-                        $episodePerson->id,
+                        $person->podcast_id,
+                        $person->episode_id,
+                        $person->id,
                     ),
                     [
                         'variant' => 'danger',
@@ -82,11 +84,10 @@
             },
         ],
     ],
-    $episodePersons,
+    $episode->persons,
 ) ?>
 
 <?= form_section_close() ?>
-<?php endif; ?>
 
 
 <?= form_section(
@@ -117,10 +118,7 @@
     'person_group_role[]',
     $taxonomyOptions,
     old('person_group_role', []),
-    [
-        'id' => 'person_group_role',
-        'class' => 'form-select mb-4',
-    ],
+    ['id' => 'person_group_role', 'class' => 'form-select mb-4'],
 ) ?>
         
     
diff --git a/app/Views/admin/episode/soundbites.php b/app/Views/admin/episode/soundbites.php
index e8c7579607..462fa1a05e 100644
--- a/app/Views/admin/episode/soundbites.php
+++ b/app/Views/admin/episode/soundbites.php
@@ -53,34 +53,40 @@
         <tr>
             <td class="px-1 py-2 font-medium bg-white border border-light-blue-500"><?= form_input(
                 [
-                    'id' => "soundbites_array[{$soundbite->id}][start_time]",
-                    'name' => "soundbites_array[{$soundbite->id}][start_time]",
+                    'type' => 'number',
+                    'min' => 0,
+                    'max' => $episode->audio_file_duration,
+                    'step' => 'any',
+                    'id' => "soundbites[{$soundbite->id}][start_time]",
+                    'name' => "soundbites[{$soundbite->id}][start_time]",
                     'class' => 'form-input w-full border-none text-center',
                     'value' => $soundbite->start_time,
                     'data-type' => 'soundbite-field',
                     'data-field-type' => 'start-time',
                     'data-soundbite-id' => $soundbite->id,
                     'required' => 'required',
-                    'min' => '0',
                 ],
             ) ?></td>
             <td class="px-1 py-2 font-medium bg-white border border-light-blue-500"><?= form_input(
                 [
-                    'id' => "soundbites_array[{$soundbite->id}][duration]",
-                    'name' => "soundbites_array[{$soundbite->id}][duration]",
+                    'type' => 'number',
+                    'min' => 0,
+                    'max' => $episode->audio_file_duration,
+                    'step' => 'any',
+                    'id' => "soundbites[{$soundbite->id}][duration]",
+                    'name' => "soundbites[{$soundbite->id}][duration]",
                     'class' => 'form-input w-full border-none text-center',
                     'value' => $soundbite->duration,
                     'data-type' => 'soundbite-field',
                     'data-field-type' => 'duration',
                     'data-soundbite-id' => $soundbite->id,
                     'required' => 'required',
-                    'min' => '0',
                 ],
             ) ?></td>
             <td class="px-1 py-2 font-medium bg-white border border-light-blue-500"><?= form_input(
                 [
-                    'id' => "soundbites_array[{$soundbite->id}][label]",
-                    'name' => "soundbites_array[{$soundbite->id}][label]",
+                    'id' => "soundbites[{$soundbite->id}][label]",
+                    'name' => "soundbites[{$soundbite->id}][label]",
                     'class' => 'form-input w-full border-none',
                     'value' => $soundbite->label,
                 ],
@@ -116,20 +122,27 @@
         <tr>
         <td class="px-1 py-4 font-medium bg-white border border-light-blue-500"><?= form_input(
             [
-                'id' => 'soundbites_array[0][start_time]',
-                'name' => 'soundbites_array[0][start_time]',
+                'type' => 'number',
+                'min' => 0,
+                'max' => $episode->audio_file_duration,
+                'step' => 'any',
+                'id' => 'soundbites[0][start_time]',
+                'name' => 'soundbites[0][start_time]',
                 'class' => 'form-input w-full border-none text-center',
                 'value' => old('start_time'),
                 'data-soundbite-id' => '0',
                 'data-type' => 'soundbite-field',
                 'data-field-type' => 'start-time',
-                'min' => '0',
             ],
         ) ?></td>
         <td class="px-1 py-4 font-medium bg-white border border-light-blue-500"><?= form_input(
             [
-                'id' => 'soundbites_array[0][duration]',
-                'name' => 'soundbites_array[0][duration]',
+                'type' => 'number',
+                'min' => 0,
+                'max' => $episode->audio_file_duration,
+                'step' => 'any',
+                'id' => 'soundbites[0][duration]',
+                'name' => 'soundbites[0][duration]',
                 'class' => 'form-input w-full border-none text-center',
                 'value' => old('duration'),
                 'data-soundbite-id' => '0',
@@ -140,8 +153,8 @@
         ) ?></td>
         <td class="px-1 py-4 font-medium bg-white border border-light-blue-500"><?= form_input(
             [
-                'id' => 'soundbites_array[0][label]',
-                'name' => 'soundbites_array[0][label]',
+                'id' => 'soundbites[0][label]',
+                'name' => 'soundbites[0][label]',
                 'class' => 'form-input w-full border-none',
                 'value' => old('label'),
             ],
@@ -149,7 +162,7 @@
         <td class="px-4 py-2"><?= icon_button(
             'play',
             lang('Episode.soundbites_form.play'),
-            null,
+            '',
             ['variant' => 'primary'],
             [
                 'data-type' => 'play-soundbite',
@@ -170,13 +183,12 @@
         </td><td class="px-4 py-2"><?= icon_button(
             'timer',
             lang('Episode.soundbites_form.bookmark'),
-            null,
+            '',
             ['variant' => 'info'],
             [
                 'data-type' => 'get-soundbite',
-                'data-start-time-field-name' =>
-                    'soundbites_array[0][start_time]',
-                'data-duration-field-name' => 'soundbites_array[0][duration]',
+                'data-start-time-field-name' => 'soundbites[0][start_time]',
+                'data-duration-field-name' => 'soundbites[0][duration]',
             ],
         ) ?></td></tr>
     </tbody>
diff --git a/app/Views/admin/podcast/person.php b/app/Views/admin/podcast/person.php
deleted file mode 100644
index f0a5b99cf0..0000000000
--- a/app/Views/admin/podcast/person.php
+++ /dev/null
@@ -1,135 +0,0 @@
-<?= $this->extend('admin/_layout') ?>
-
-<?= $this->section('title') ?>
-<?= lang('Person.podcast_form.title') ?>
-<?= $this->endSection() ?>
-
-<?= $this->section('pageTitle') ?>
-<?= lang('Person.podcast_form.title') ?> (<?= count($podcastPersons) ?>)
-<?= $this->endSection() ?>
-
-<?= $this->section('headerRight') ?>
-<?= button(
-    lang('Person.create'),
-    route_to('person-create'),
-    ['variant' => 'primary', 'iconLeft' => 'add'],
-    ['class' => 'mr-2'],
-) ?>
-<?= $this->endSection() ?>
-
-<?= $this->section('content') ?>
-
-<?= form_open(route_to('podcast-person-edit', $podcast->id), [
-    'method' => 'post',
-    'class' => 'flex flex-col',
-]) ?>
-<?= csrf_field() ?>
-
-<?php if ($podcastPersons): ?>
-
-    <?= form_section(
-        lang('Person.podcast_form.manage_section_title'),
-        lang('Person.podcast_form.manage_section_subtitle'),
-    ) ?>
-
-
-    <?= data_table(
-        [
-            [
-                'header' => lang('Person.podcast_form.person'),
-                'cell' => function ($podcastPerson) {
-                    return '<div class="flex">' .
-                        '<a href="' .
-                        route_to('person-view', $podcastPerson->person->id) .
-                        "\"><img src=\"{$podcastPerson->person->image->thumbnail_url}\" alt=\"{$podcastPerson->person->full_name}\" class=\"object-cover w-16 h-16 rounded-full\" /></a>" .
-                        '<div class="flex flex-col ml-3">' .
-                        $podcastPerson->person->full_name .
-                        ($podcastPerson->person_group &&
-                        $podcastPerson->person_role
-                            ? '<span class="text-sm text-gray-600">' .
-                                lang(
-                                    "PersonsTaxonomy.persons.{$podcastPerson->person_group}.label",
-                                ) .
-                                ' â–¸ ' .
-                                lang(
-                                    "PersonsTaxonomy.persons.{$podcastPerson->person_group}.roles.{$podcastPerson->person_role}.label",
-                                ) .
-                                '</span>'
-                            : '') .
-                        (empty($podcastPerson->person->information_url)
-                            ? ''
-                            : "<a href=\"{$podcastPerson->person->information_url}\" target=\"_blank\" rel=\"noreferrer noopener\" class=\"text-sm text-blue-800 hover:underline\">" .
-                                $podcastPerson->person->information_url .
-                                '</a>') .
-                        '</div></div>';
-                },
-            ],
-            [
-                'header' => lang('Common.actions'),
-                'cell' => function ($podcastPerson): string {
-                    return button(
-                        lang('Person.podcast_form.remove'),
-                        route_to(
-                            'podcast-person-remove',
-                            $podcastPerson->podcast_id,
-                            $podcastPerson->id,
-                        ),
-                        [
-                            'variant' => 'danger',
-                            'size' => 'small',
-                        ],
-                    );
-                },
-            ],
-        ],
-        $podcastPersons,
-    ) ?>
-
-    <?= form_section_close() ?>
-<?php endif; ?>
-
-
-<?= form_section(
-    lang('Person.podcast_form.add_section_title'),
-    lang('Person.podcast_form.add_section_subtitle'),
-) ?>
-
-<?= form_label(
-    lang('Person.podcast_form.person'),
-    'person',
-    [],
-    lang('Person.podcast_form.person_hint'),
-) ?>
-<?= form_multiselect('person[]', $personOptions, old('person', []), [
-    'id' => 'person',
-    'class' => 'form-select mb-4',
-    'required' => 'required',
-]) ?>
-
-<?= form_label(
-    lang('Person.podcast_form.group_role'),
-    'group_role',
-    [],
-    lang('Person.podcast_form.group_role_hint'),
-    true,
-) ?>
-<?= form_multiselect(
-    'person_group_role[]',
-    $taxonomyOptions,
-    old('person_group_role', []),
-    [
-        'id' => 'person_group_role',
-        'class' => 'form-select mb-4',
-    ],
-) ?>
-
-<?= form_section_close() ?>
-<?= button(
-    lang('Person.podcast_form.submit_add'),
-    '',
-    ['variant' => 'primary'],
-    ['type' => 'submit', 'class' => 'self-end'],
-) ?>
-<?= form_close() ?>
-
-<?= $this->endSection() ?>
diff --git a/app/Views/admin/podcast/persons.php b/app/Views/admin/podcast/persons.php
new file mode 100644
index 0000000000..8edb6876a2
--- /dev/null
+++ b/app/Views/admin/podcast/persons.php
@@ -0,0 +1,129 @@
+<?= $this->extend('admin/_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('Person.podcast_form.title') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('pageTitle') ?>
+<?= lang('Person.podcast_form.title') ?> (<?= count($podcast->persons) ?>)
+<?= $this->endSection() ?>
+
+<?= $this->section('headerRight') ?>
+<?= button(
+    lang('Person.create'),
+    route_to('person-create'),
+    ['variant' => 'primary', 'iconLeft' => 'add'],
+    ['class' => 'mr-2'],
+) ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<?= form_open(route_to('podcast-person-edit', $podcast->id), [
+    'method' => 'post',
+    'class' => 'flex flex-col',
+]) ?>
+<?= csrf_field() ?>
+
+<?= form_section(
+    lang('Person.podcast_form.manage_section_title'),
+    lang('Person.podcast_form.manage_section_subtitle'),
+) ?>
+
+
+<?= data_table(
+    [
+        [
+            'header' => lang('Person.podcast_form.person'),
+            'cell' => function ($person) {
+                return '<div class="flex">' .
+                    '<a href="' .
+                    route_to('person-view', $person->id) .
+                    "\"><img src=\"{$person->image->thumbnail_url}\" alt=\"{$person->full_name}\" class=\"object-cover w-16 h-16 rounded-full\" /></a>" .
+                    '<div class="flex flex-col ml-3">' .
+                    $person->full_name .
+                    implode(
+                        '',
+                        array_map(function ($role) {
+                            return '<span class="text-sm text-gray-600">' .
+                                lang(
+                                    "PersonsTaxonomy.persons.{$role->group}.label",
+                                ) .
+                                ' › ' .
+                                lang(
+                                    "PersonsTaxonomy.persons.{$role->group}.roles.{$role->role}.label",
+                                ) .
+                                '</span>';
+                        }, $person->roles),
+                    ) .
+                    ($person->information_url === null
+                        ? ''
+                        : "<a href=\"{$person->information_url}\" target=\"_blank\" rel=\"noreferrer noopener\" class=\"text-sm text-blue-800 hover:underline\">" .
+                            $person->information_url .
+                            '</a>') .
+                    '</div></div>';
+            },
+        ],
+        [
+            'header' => lang('Common.actions'),
+            'cell' => function ($person): string {
+                return button(
+                    lang('Person.podcast_form.remove'),
+                    route_to(
+                        'podcast-person-remove',
+                        $person->podcast_id,
+                        $person->id,
+                    ),
+                    [
+                        'variant' => 'danger',
+                        'size' => 'small',
+                    ],
+                );
+            },
+        ],
+    ],
+    $podcast->persons,
+) ?>
+
+<?= form_section_close() ?>
+
+
+<?= form_section(
+    lang('Person.podcast_form.add_section_title'),
+    lang('Person.podcast_form.add_section_subtitle'),
+) ?>
+
+<?= form_label(
+    lang('Person.podcast_form.person'),
+    'person',
+    [],
+    lang('Person.podcast_form.person_hint'),
+) ?>
+<?= form_multiselect('persons[]', $personOptions, old('persons', []), [
+    'id' => 'persons',
+    'class' => 'form-select mb-4',
+    'required' => 'required',
+]) ?>
+
+<?= form_label(
+    lang('Person.podcast_form.roles'),
+    'roles',
+    [],
+    lang('Person.podcast_form.roles_hint'),
+    true,
+) ?>
+<?= form_multiselect('roles[]', $taxonomyOptions, old('roles', []), [
+    'id' => 'roles',
+    'class' => 'form-select mb-4',
+]) ?>
+
+<?= form_section_close() ?>
+<?= button(
+    lang('Person.podcast_form.submit_add'),
+    '',
+    ['variant' => 'primary'],
+    ['type' => 'submit', 'class' => 'self-end'],
+) ?>
+<?= form_close() ?>
+
+<?= $this->endSection() ?>
diff --git a/app/Views/errors/html/error_exception.php b/app/Views/errors/html/error_exception.php
index 5ec36809c0..d8ef20ebc0 100644
--- a/app/Views/errors/html/error_exception.php
+++ b/app/Views/errors/html/error_exception.php
@@ -3,7 +3,7 @@
 use Config\Services;
 use CodeIgniter\CodeIgniter;
 
-$error_id = uniqid('error', true);
+$errorId = uniqid('error', true);
 ?>
 <!doctype html>
 <html>
@@ -103,12 +103,12 @@ $error_id = uniqid('error', true);
              $row['class'] . $row['type'] . $row['function'],
          ) ?>
 									<?php if (!empty($row['args'])): ?>
-										<?php $args_id = $error_id . 'args' . $index; ?>
+										<?php $argsId = $errorId . 'args' . $index; ?>
 										( <a href="#" onclick="return toggle('<?= esc(
-              $args_id,
+              $argsId,
               'attr',
           ) ?>');">arguments</a> )
-							<div class="args" id="<?= esc($args_id, 'attr') ?>">
+							<div class="args" id="<?= esc($argsId, 'attr') ?>">
 								<table cellspacing="0">
 
 									<?php
diff --git a/app/Views/podcast/_layout_authenticated.php b/app/Views/podcast/_layout_authenticated.php
index b0ee155529..33a6cc638c 100644
--- a/app/Views/podcast/_layout_authenticated.php
+++ b/app/Views/podcast/_layout_authenticated.php
@@ -27,7 +27,7 @@
                     'text-2xl inline-flex items-baseline font-bold font-display',
             ],
         ) ?>
-        <?php if (user()->podcasts): ?>
+        <?php if (user()->podcasts !== []): ?>
             <button type="button" class="inline-flex items-center px-6 py-2 mt-auto font-semibold outline-none focus:ring" id="interact-as-dropdown" data-dropdown="button" data-dropdown-target="interact-as-dropdown-menu" aria-haspopup="true" aria-expanded="false">
                 <img src="<?= interact_as_actor()
                     ->avatar_image_url ?>" class="w-8 h-8 mr-2 rounded-full" />
diff --git a/app/Views/podcast/_partials/header.php b/app/Views/podcast/_partials/header.php
index 2273053ab1..38e46073c2 100644
--- a/app/Views/podcast/_partials/header.php
+++ b/app/Views/podcast/_partials/header.php
@@ -42,37 +42,7 @@
                 </span>
             <?php endforeach; ?>
         </div>
-        <?php if (!empty($persons)): ?>
-        <div class="flex w-full mb-6 space-x-2 overflow-y-auto">
-            <?php foreach ($persons as $person): ?>
-                <?php if ($person['information_url']): ?>
-                    <a href="<?= $person[
-                        'information_url'
-                    ] ?>" target="_blank" rel="noreferrer noopener" class="flex-shrink-0">
-                        <img
-                        src="<?= $person['thumbnail_url'] ?>"
-                        alt="<?= $person['full_name'] ?>"
-                        class="object-cover w-12 h-12 rounded-full"
-                        data-toggle="tooltip"
-                        data-placement="bottom"
-                        title="[<?= $person['full_name'] ?>] <?= $person[
-    'roles'
-] ?>" />
-                    </a>
-                <?php else: ?>
-                    <img
-                    src="<?= $person['thumbnail_url'] ?>"
-                    alt="<?= $person['full_name'] ?>"
-                    class="object-cover w-12 h-12 rounded-full"
-                    data-toggle="tooltip"
-                    data-placement="bottom"
-                    title="[<?= $person['full_name'] ?>] <?= $person[
-    'roles'
-] ?>" />
-                <?php endif; ?>
-            <?php endforeach; ?>
-        </div>
-        <?php endif; ?>
+        <?= person_list($podcast->persons, 'mb-6') ?>
         <div class="space-x-4">
             <a href="#" class="hover:underline"><?= lang('Podcast.followers', [
                 'numberOfFollowers' => $podcast->actor->followers_count,
diff --git a/app/Views/podcast/episode.php b/app/Views/podcast/episode.php
index a6ea7b1816..694313ae88 100644
--- a/app/Views/podcast/episode.php
+++ b/app/Views/podcast/episode.php
@@ -111,37 +111,8 @@
                         ],
                     ) ?>
                 </div>
-                <?php if ($episode->location !== null): ?>
-                    <?= location_link($episode->location, 'text-sm mb-4') ?>
-                <?php endif; ?>
-                <?php if ($episodePersons): ?>
-                    <div class="flex w-full space-x-2 overflow-y-auto">
-                        <?php foreach ($episodePersons as $person): ?>
-                            <?php if ($person['information_url']): ?>
-                                <a href="<?= $person[
-                                    'information_url'
-                                ] ?>" target="_blank" rel="noreferrer noopener" class="flex-shrink-0">
-                                    <img src="<?= $person[
-                                        'thumbnail_url'
-                                    ] ?>" alt="<?= $person[
-    'full_name'
-] ?>" class="object-cover w-12 h-12 rounded-full" data-toggle="tooltip"
-data-placement="bottom" title="[<?= $person['full_name'] ?>] <?= $person[
-    'roles'
-] ?>" /></a>
-                            <?php else: ?>
-                                <img src="<?= $person[
-                                    'thumbnail_url'
-                                ] ?>" alt="<?= $person[
-    'full_name'
-] ?>" class="object-cover w-12 h-12 rounded-full" data-toggle="tooltip"
-data-placement="bottom" title="[<?= $person['full_name'] ?>] <?= $person[
-    'roles'
-] ?>" />
-                            <?php endif; ?>
-                        <?php endforeach; ?>
-                    </div>
-                <?php endif; ?>
+                <?= location_link($episode->location, 'text-sm mb-4') ?>
+                <?= person_list($episode->persons) ?>
             </div>
         </div>
         <audio controls preload="none" class="w-full mt-auto">
-- 
GitLab