From c0a22829bd87d48535a86e60c6cd7280e44683a2 Mon Sep 17 00:00:00 2001
From: Benjamin Bellamy <ben@podlibre.org>
Date: Wed, 23 Dec 2020 14:11:38 +0000
Subject: [PATCH] feat(rss): add podcast:location tag

---
 app/Controllers/Admin/Episode.php             |  2 +
 app/Controllers/Admin/Podcast.php             |  2 +
 app/Controllers/Admin/PodcastImport.php       | 41 +++++++++++--
 .../2020-05-30-101500_add_podcasts.php        | 15 +++++
 .../2020-06-05-170000_add_episodes.php        | 15 +++++
 app/Entities/Episode.php                      | 31 ++++++++++
 app/Entities/Podcast.php                      | 31 ++++++++++
 app/Helpers/components_helper.php             | 59 ++++++++++++++++++-
 app/Helpers/location_helper.php               | 52 ++++++++++++++++
 app/Helpers/rss_helper.php                    | 38 +++++++++++-
 app/Language/en/Episode.php                   |  4 ++
 app/Language/en/Podcast.php                   |  4 ++
 app/Language/fr/Episode.php                   |  6 +-
 app/Language/fr/Podcast.php                   |  4 ++
 app/Models/EpisodeModel.php                   |  3 +
 app/Models/PodcastModel.php                   |  3 +
 app/Views/_assets/icons/map-pin.svg           |  1 +
 app/Views/admin/episode/create.php            | 19 ++++++
 app/Views/admin/episode/edit.php              | 19 ++++++
 app/Views/admin/episode/view.php              |  6 ++
 app/Views/admin/podcast/create.php            | 40 +++++++++++--
 app/Views/admin/podcast/edit.php              | 23 +++++++-
 app/Views/admin/podcast/view.php              |  6 ++
 app/Views/episode.php                         |  6 ++
 app/Views/podcast.php                         |  6 ++
 25 files changed, 421 insertions(+), 15 deletions(-)
 create mode 100644 app/Helpers/location_helper.php
 create mode 100644 app/Views/_assets/icons/map-pin.svg

diff --git a/app/Controllers/Admin/Episode.php b/app/Controllers/Admin/Episode.php
index d85c58db18..8ad5ce6f92 100644
--- a/app/Controllers/Admin/Episode.php
+++ b/app/Controllers/Admin/Episode.php
@@ -126,6 +126,7 @@ class Episode extends BaseController
             'enclosure' => $this->request->getFile('enclosure'),
             'description_markdown' => $this->request->getPost('description'),
             'image' => $this->request->getFile('image'),
+            'location' => $this->request->getPost('location_name'),
             'transcript' => $this->request->getFile('transcript'),
             'chapters' => $this->request->getFile('chapters'),
             'parental_advisory' =>
@@ -222,6 +223,7 @@ class Episode extends BaseController
         $this->episode->description_markdown = $this->request->getPost(
             'description'
         );
+        $this->episode->location = $this->request->getPost('location_name');
         $this->episode->parental_advisory =
             $this->request->getPost('parental_advisory') !== 'undefined'
                 ? $this->request->getPost('parental_advisory')
diff --git a/app/Controllers/Admin/Podcast.php b/app/Controllers/Admin/Podcast.php
index 359bee6162..706a9e6c0e 100644
--- a/app/Controllers/Admin/Podcast.php
+++ b/app/Controllers/Admin/Podcast.php
@@ -161,6 +161,7 @@ class Podcast extends BaseController
             'publisher' => $this->request->getPost('publisher'),
             'type' => $this->request->getPost('type'),
             'copyright' => $this->request->getPost('copyright'),
+            'location' => $this->request->getPost('location_name'),
             'payment_pointer' => $this->request->getPost('payment_pointer'),
             'is_blocked' => $this->request->getPost('is_blocked') === 'yes',
             'is_completed' => $this->request->getPost('complete') === 'yes',
@@ -254,6 +255,7 @@ class Podcast 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->payment_pointer = $this->request->getPost(
             'payment_pointer'
         );
diff --git a/app/Controllers/Admin/PodcastImport.php b/app/Controllers/Admin/PodcastImport.php
index c4c5def8a7..0ae92f15ca 100644
--- a/app/Controllers/Admin/PodcastImport.php
+++ b/app/Controllers/Admin/PodcastImport.php
@@ -121,11 +121,13 @@ class PodcastImport extends BaseController
                     $channelDescriptionHtml
                 ),
                 'description_html' => $channelDescriptionHtml,
-                'image' => $nsItunes->image && !empty($nsItunes->image->attributes())
-                    ? download_file($nsItunes->image->attributes())
-                    : ($feed->channel[0]->image && !empty($feed->channel[0]->image->url)
-                        ? download_file($feed->channel[0]->image->url)
-                        : null),
+                'image' =>
+                    $nsItunes->image && !empty($nsItunes->image->attributes())
+                        ? download_file($nsItunes->image->attributes())
+                        : ($feed->channel[0]->image &&
+                        !empty($feed->channel[0]->image->url)
+                            ? download_file($feed->channel[0]->image->url)
+                            : null),
                 'language_code' => $this->request->getPost('language'),
                 'category_id' => $this->request->getPost('category'),
                 'parental_advisory' => empty($nsItunes->explicit)
@@ -146,6 +148,19 @@ class PodcastImport extends BaseController
                 'is_completed' => empty($nsItunes->complete)
                     ? false
                     : $nsItunes->complete === 'yes',
+                'location_name' => !$nsPodcast->location
+                    ? null
+                    : $nsPodcast->location->attributes()['name'],
+                'location_geo' =>
+                    !$nsPodcast->location ||
+                    empty($nsPodcast->location->attributes()['geo'])
+                        ? null
+                        : $nsPodcast->location->attributes()['geo'],
+                'location_osmid' =>
+                    !$nsPodcast->location ||
+                    empty($nsPodcast->location->attributes()['osmid'])
+                        ? null
+                        : $nsPodcast->location->attributes()['osmid'],
                 'created_by' => user(),
                 'updated_by' => user(),
             ]);
@@ -243,6 +258,9 @@ class PodcastImport extends BaseController
             $nsItunes = $item->children(
                 'http://www.itunes.com/dtds/podcast-1.0.dtd'
             );
+            $nsPodcast = $item->children(
+                'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md'
+            );
 
             $slug = slugify(
                 $this->request->getPost('slug_field') === 'title'
@@ -306,6 +324,19 @@ class PodcastImport extends BaseController
                 'is_blocked' => empty($nsItunes->block)
                     ? false
                     : $nsItunes->block === 'yes',
+                'location_name' => !$nsPodcast->location
+                    ? null
+                    : $nsPodcast->location->attributes()['name'],
+                'location_geo' =>
+                    !$nsPodcast->location ||
+                    empty($nsPodcast->location->attributes()['geo'])
+                        ? null
+                        : $nsPodcast->location->attributes()['geo'],
+                'location_osmid' =>
+                    !$nsPodcast->location ||
+                    empty($nsPodcast->location->attributes()['osmid'])
+                        ? null
+                        : $nsPodcast->location->attributes()['osmid'],
                 'created_by' => user(),
                 'updated_by' => user(),
                 'published_at' => strtotime($item->pubDate),
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 30ba95eda5..19a816163a 100644
--- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
+++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
@@ -123,6 +123,21 @@ class AddPodcasts extends Migration
                 'comment' => 'Wallet address for Web Monetization payments',
                 'null' => true,
             ],
+            'location_name' => [
+                'type' => 'VARCHAR',
+                'constraint' => 128,
+                'null' => true,
+            ],
+            'location_geo' => [
+                'type' => 'VARCHAR',
+                'constraint' => 32,
+                'null' => true,
+            ],
+            'location_osmid' => [
+                'type' => 'VARCHAR',
+                'constraint' => 12,
+                'null' => true,
+            ],
             'created_by' => [
                 'type' => 'INT',
                 'unsigned' => true,
diff --git a/app/Database/Migrations/2020-06-05-170000_add_episodes.php b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
index 8147df8fb1..7d33d94226 100644
--- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php
+++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
@@ -109,6 +109,21 @@ class AddEpisodes extends Migration
                 'constraint' => 1,
                 'default' => 0,
             ],
+            'location_name' => [
+                'type' => 'VARCHAR',
+                'constraint' => 128,
+                'null' => true,
+            ],
+            'location_geo' => [
+                'type' => 'VARCHAR',
+                'constraint' => 32,
+                'null' => true,
+            ],
+            'location_osmid' => [
+                'type' => 'VARCHAR',
+                'constraint' => 12,
+                'null' => true,
+            ],
             'created_by' => [
                 'type' => 'INT',
                 'unsigned' => true,
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index 65c14e0f17..4249defe87 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -120,6 +120,9 @@ class Episode extends Entity
         'season_number' => '?integer',
         'type' => 'string',
         'is_blocked' => 'boolean',
+        'location_name' => '?string',
+        'location_geo' => '?string',
+        'location_osmid' => '?string',
         'created_by' => 'integer',
         'updated_by' => 'integer',
     ];
@@ -479,4 +482,32 @@ class Episode extends Entity
 
         return 'scheduled';
     }
+
+    /**
+     * Saves the location name and fetches OpenStreetMap info
+     *
+     * @param string $locationName
+     *
+     */
+    public function setLocation($locationName = null)
+    {
+        helper('location');
+
+        if (
+            $locationName &&
+            (empty($this->attributes['location_name']) ||
+                $this->attributes['location_name'] != $locationName)
+        ) {
+            $this->attributes['location_name'] = $locationName;
+            if ($location = fetch_osm_location($locationName)) {
+                $this->attributes['location_geo'] = $location['geo'];
+                $this->attributes['location_osmid'] = $location['osmid'];
+            }
+        } elseif (empty($locationName)) {
+            $this->attributes['location_name'] = null;
+            $this->attributes['location_geo'] = null;
+            $this->attributes['location_osmid'] = null;
+        }
+        return $this;
+    }
 }
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 8b8076b5b4..8782a0a7d6 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -96,6 +96,9 @@ class Podcast extends Entity
         'is_locked' => 'boolean',
         'imported_feed_url' => '?string',
         'new_feed_url' => '?string',
+        'location_name' => '?string',
+        'location_geo' => '?string',
+        'location_osmid' => '?string',
         'payment_pointer' => '?string',
         'created_by' => 'integer',
         'updated_by' => 'integer',
@@ -367,4 +370,32 @@ class Podcast extends Entity
 
         return $this->other_categories_ids;
     }
+
+    /**
+     * Saves the location name and fetches OpenStreetMap info
+     *
+     * @param string $locationName
+     *
+     */
+    public function setLocation($locationName = null)
+    {
+        helper('location');
+
+        if (
+            $locationName &&
+            (empty($this->attributes['location_name']) ||
+                $this->attributes['location_name'] != $locationName)
+        ) {
+            $this->attributes['location_name'] = $locationName;
+            if ($location = fetch_osm_location($locationName)) {
+                $this->attributes['location_geo'] = $location['geo'];
+                $this->attributes['location_osmid'] = $location['osmid'];
+            }
+        } elseif (empty($locationName)) {
+            $this->attributes['location_name'] = null;
+            $this->attributes['location_geo'] = null;
+            $this->attributes['location_osmid'] = null;
+        }
+        return $this;
+    }
 }
diff --git a/app/Helpers/components_helper.php b/app/Helpers/components_helper.php
index df2c00b8f9..3d69bab2cc 100644
--- a/app/Helpers/components_helper.php
+++ b/app/Helpers/components_helper.php
@@ -318,7 +318,7 @@ if (!function_exists('episode_numbering')) {
      * @param string    $class styling classes
      * @param string    $is_abbr component will show abbreviated numbering if true
      *
-     * @return string
+     * @return string|null
      */
     function episode_numbering(
         $episodeNumber = null,
@@ -368,4 +368,61 @@ if (!function_exists('episode_numbering')) {
     }
 }
 
+if (!function_exists('location_link')) {
+    /**
+     * Returns link to display from location info
+     *
+     * @param string $locationName
+     * @param string $locationGeo
+     * @param string $locationOsmid
+     *
+     * @return string
+     */
+    function location_link(
+        $locationName,
+        $locationGeo,
+        $locationOsmid,
+        $class = ''
+    ) {
+        $link = null;
+        if (!empty($locationName)) {
+            $uri = '';
+            if (!empty($locationOsmid)) {
+                $uri =
+                    'https://www.openstreetmap.org/' .
+                    ['N' => 'node', 'W' => 'way', 'R' => 'relation'][
+                        substr($locationOsmid, 0, 1)
+                    ] .
+                    '/' .
+                    substr($locationOsmid, 1);
+            } elseif (!empty($locationGeo)) {
+                $uri =
+                    'https://www.openstreetmap.org/#map=17/' .
+                    str_replace(',', '/', substr($locationGeo, 4));
+            } else {
+                $uri =
+                    'https://www.openstreetmap.org/search?query=' .
+                    urlencode($locationName);
+            }
+            $link = button(
+                $locationName,
+                $uri,
+                [
+                    'variant' => 'default',
+                    'size' => 'small',
+                    'isRoundedFull' => true,
+                    'iconLeft' => 'map-pin',
+                ],
+                [
+                    'class' =>
+                        'text-gray-800' . (empty($class) ? '' : " $class"),
+                    'target' => '_blank',
+                    'rel' => 'noreferrer noopener',
+                ]
+            );
+        }
+        return $link;
+    }
+}
+
 // ------------------------------------------------------------------------
diff --git a/app/Helpers/location_helper.php b/app/Helpers/location_helper.php
new file mode 100644
index 0000000000..4b4207c383
--- /dev/null
+++ b/app/Helpers/location_helper.php
@@ -0,0 +1,52 @@
+<?php
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+/**
+ * Fetches places from Nominatim OpenStreetMap
+ *
+ * @param string $locationName
+ *
+ * @return array|null
+ */
+function fetch_osm_location($locationName)
+{
+    $osmObject = null;
+    if (!empty($locationName)) {
+        try {
+            $client = \Config\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);
+            $osmObject = [
+                'geo' =>
+                    empty($places[0]['lat']) || empty($places[0]['lon'])
+                        ? null
+                        : "geo:{$places[0]['lat']},{$places[0]['lon']}",
+                'osmid' => empty($places[0]['osm_type'])
+                    ? null
+                    : strtoupper(substr($places[0]['osm_type'], 0, 1)) .
+                        $places[0]['osm_id'],
+            ];
+        } catch (\Exception $e) {
+            //If things go wrong the show must go on
+            log_message('critical', $e);
+        }
+    }
+    return $osmObject;
+}
diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php
index e1e8e8670a..bfc1798493 100644
--- a/app/Helpers/rss_helper.php
+++ b/app/Helpers/rss_helper.php
@@ -65,7 +65,23 @@ function get_rss_feed($podcast, $serviceSlug = '')
     $itunes_image = $channel->addChild('image', null, $itunes_namespace);
     $itunes_image->addAttribute('href', $podcast->image->original_url);
     $channel->addChild('language', $podcast->language_code);
-
+    if (!empty($podcast->location_name)) {
+        $locationElement = $channel->addChild(
+            'location',
+            null,
+            $podcast_namespace
+        );
+        $locationElement->addAttribute(
+            'name',
+            htmlspecialchars($podcast->location_name)
+        );
+        if (!empty($podcast->location_geo)) {
+            $locationElement->addAttribute('geo', $podcast->location_geo);
+        }
+        if (!empty($podcast->location_osmid)) {
+            $locationElement->addAttribute('osmid', $podcast->location_osmid);
+        }
+    }
     if (!empty($podcast->payment_pointer)) {
         $valueElement = $channel->addChild('value', null, $podcast_namespace);
         $valueElement->addAttribute('type', 'webmonetization');
@@ -203,6 +219,26 @@ function get_rss_feed($podcast, $serviceSlug = '')
             'pubDate',
             $episode->published_at->format(DATE_RFC1123)
         );
+        if (!empty($episode->location_name)) {
+            $locationElement = $item->addChild(
+                'location',
+                null,
+                $podcast_namespace
+            );
+            $locationElement->addAttribute(
+                'name',
+                htmlspecialchars($episode->location_name)
+            );
+            if (!empty($episode->location_geo)) {
+                $locationElement->addAttribute('geo', $episode->location_geo);
+            }
+            if (!empty($episode->location_osmid)) {
+                $locationElement->addAttribute(
+                    'osmid',
+                    $episode->location_osmid
+                );
+            }
+        }
         $item->addChildWithCDATA('description', $episode->description_html);
         $item->addChild(
             'duration',
diff --git a/app/Language/en/Episode.php b/app/Language/en/Episode.php
index e9886c15b5..00a487219f 100644
--- a/app/Language/en/Episode.php
+++ b/app/Language/en/Episode.php
@@ -83,6 +83,10 @@ return [
         'chapters' => 'Chapters',
         'chapters_hint' => 'File should be in JSON Chapters Format.',
         'chapters_delete' => 'Delete chapters',
+        'location_section_title' => 'Location',
+        'location_section_subtitle' => 'What place is this episode about?',
+        'location_name' => 'Location name or address',
+        'location_name_hint' => 'This can be a real place or fictional',
         'submit_create' => 'Create episode',
         'submit_edit' => 'Save episode',
     ],
diff --git a/app/Language/en/Podcast.php b/app/Language/en/Podcast.php
index 7e7c7378d2..86f7b4c9bd 100644
--- a/app/Language/en/Podcast.php
+++ b/app/Language/en/Podcast.php
@@ -61,6 +61,10 @@ return [
         'publisher_hint' =>
             'The group responsible for creating the show. Often refers to the parent company or network of a podcast. This field is sometimes labeled as ’Author’.',
         'copyright' => 'Copyright',
+        'location_section_title' => 'Location',
+        'location_section_subtitle' => 'What place is this podcast about?',
+        'location_name' => 'Location name or address',
+        'location_name_hint' => 'This can be a real place or fictional',
         'monetization_section_title' => 'Monetization',
         'monetization_section_subtitle' =>
             'Earn money thanks to your audience.',
diff --git a/app/Language/fr/Episode.php b/app/Language/fr/Episode.php
index 03c5702092..e691f9eed7 100644
--- a/app/Language/fr/Episode.php
+++ b/app/Language/fr/Episode.php
@@ -83,7 +83,11 @@ return [
         'transcript_delete' => 'Supprimer la transcription',
         'chapters' => 'Chapitrage',
         'chapters_hint' => 'Le fichier doit être en "JSON Chapters Format".',
-        'chapters_delete' => 'Supprimer le chaptrage',
+        'chapters_delete' => 'Supprimer le chapitrage',
+        'location_section_title' => 'Localisation',
+        'location_section_subtitle' => 'De quel lieu cet épisode parle-t-il ?',
+        'location_name' => 'Nom ou adresse du lieu',
+        'location_name_hint' => 'Ce lieu peut être réel ou fictif',
         'submit_create' => 'Créer l’épisode',
         'submit_edit' => 'Enregistrer l’épisode',
     ],
diff --git a/app/Language/fr/Podcast.php b/app/Language/fr/Podcast.php
index 754d3af776..49131cbf54 100644
--- a/app/Language/fr/Podcast.php
+++ b/app/Language/fr/Podcast.php
@@ -62,6 +62,10 @@ return [
         'publisher_hint' =>
             'Le groupe responsable de la création du podcast. Fait souvent référence à la société mère ou au réseau d’un podcast. Ce champ est parfois appelé « Auteur ».',
         'copyright' => 'Droit d’auteur',
+        'location_section_title' => 'Localisation',
+        'location_section_subtitle' => 'De quel lieu ce podcast parle-t-il ?',
+        'location_name' => 'Nom ou adresse du lieu',
+        'location_name_hint' => 'Ce lieu peut être réel ou fictif',
         'monetization_section_title' => 'Monétisation',
         'monetization_section_subtitle' =>
             'Gagnez de l’argent grâce à votre audience.',
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index c60803a215..a28d29d349 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -35,6 +35,9 @@ class EpisodeModel extends Model
         'season_number',
         'type',
         'is_blocked',
+        'location_name',
+        'location_geo',
+        'location_osmid',
         'published_at',
         'created_by',
         'updated_by',
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index ee14d835d8..7e401fd16f 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -37,6 +37,9 @@ class PodcastModel extends Model
         'is_blocked',
         'is_completed',
         'is_locked',
+        'location_name',
+        'location_geo',
+        'location_osmid',
         'payment_pointer',
         'created_by',
         'updated_by',
diff --git a/app/Views/_assets/icons/map-pin.svg b/app/Views/_assets/icons/map-pin.svg
new file mode 100644
index 0000000000..8e2366f3b9
--- /dev/null
+++ b/app/Views/_assets/icons/map-pin.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M17.657 15.657L12 21.314l-5.657-5.657a8 8 0 1 1 11.314 0zM5 22h14v2H5v-2z"/></svg>
\ No newline at end of file
diff --git a/app/Views/admin/episode/create.php b/app/Views/admin/episode/create.php
index 8553a0a982..35f6fc0d57 100644
--- a/app/Views/admin/episode/create.php
+++ b/app/Views/admin/episode/create.php
@@ -187,6 +187,25 @@
 
 <?= form_section_close() ?>
 
+<?= form_section(
+    lang('Episode.form.location_section_title'),
+    lang('Episode.form.location_section_subtitle')
+) ?>
+
+<?= form_label(
+    lang('Episode.form.location_name'),
+    'location_name',
+    [],
+    lang('Episode.form.location_name_hint'),
+    true
+) ?>
+<?= form_input([
+    'id' => 'location_name',
+    'name' => 'location_name',
+    'class' => 'form-input mb-4',
+    'value' => old('location_name'),
+]) ?>
+<?= form_section_close() ?>
 
 <?= form_section(
     lang('Episode.form.publication_section_title'),
diff --git a/app/Views/admin/episode/edit.php b/app/Views/admin/episode/edit.php
index af7018023f..9783ce5b64 100644
--- a/app/Views/admin/episode/edit.php
+++ b/app/Views/admin/episode/edit.php
@@ -190,6 +190,25 @@
 
 <?= form_section_close() ?>
 
+<?= form_section(
+    lang('Episode.form.location_section_title'),
+    lang('Episode.form.location_section_subtitle')
+) ?>
+
+<?= form_label(
+    lang('Episode.form.location_name'),
+    'location_name',
+    [],
+    lang('Episode.form.location_name_hint'),
+    true
+) ?>
+<?= form_input([
+    'id' => 'location_name',
+    'name' => 'location_name',
+    'class' => 'form-input mb-4',
+    'value' => old('location_name', $episode->location_name),
+]) ?>
+<?= form_section_close() ?>
 
 <?= form_section(
     lang('Episode.form.publication_section_title'),
diff --git a/app/Views/admin/episode/view.php b/app/Views/admin/episode/view.php
index b1c001a6fe..a66c8d710c 100644
--- a/app/Views/admin/episode/view.php
+++ b/app/Views/admin/episode/view.php
@@ -11,6 +11,12 @@
         $episode->publication_status,
         'text-sm ml-2 align-middle'
     ) ?>
+    <?= location_link(
+        $episode->location_name,
+        $episode->location_geo,
+        $episode->location_osmid,
+        'ml-2'
+    ) ?>
 <?= $this->endSection() ?>
 
 <?= $this->section('content') ?>
diff --git a/app/Views/admin/podcast/create.php b/app/Views/admin/podcast/create.php
index 8ee46f8faa..0989f6398e 100644
--- a/app/Views/admin/podcast/create.php
+++ b/app/Views/admin/podcast/create.php
@@ -27,6 +27,7 @@
     'id' => 'image',
     'name' => 'image',
     'class' => 'form-input',
+
     'required' => 'required',
     'type' => 'file',
     'accept' => '.jpg,.jpeg,.png',
@@ -58,21 +59,27 @@
     'required' => 'required',
 ]) ?>
 
-<?= form_fieldset('', [
-    'class' => 'mb-4',
-]) ?>
+<?= form_fieldset('', ['class' => 'mb-4']) ?>
     <legend>
     <?= lang('Podcast.form.type.label') .
         hint_tooltip(lang('Podcast.form.type.hint'), 'ml-1') ?>
     </legend>
     <?= form_radio(
-        ['id' => 'episodic', 'name' => 'type', 'class' => 'form-radio-btn'],
+        [
+            'id' => 'episodic',
+            'name' => 'type',
+            'class' => 'form-radio-btn',
+        ],
         'episodic',
         old('type') ? old('type') == 'episodic' : true
     ) ?>
     <label for="episodic"><?= lang('Podcast.form.type.episodic') ?></label>
     <?= form_radio(
-        ['id' => 'serial', 'name' => 'type', 'class' => 'form-radio-btn'],
+        [
+            'id' => 'serial',
+            'name' => 'type',
+            'class' => 'form-radio-btn',
+        ],
         'serial',
         old('type') ? old('type') == 'serial' : false
     ) ?>
@@ -241,6 +248,26 @@
 
 <?= form_section_close() ?>
 
+<?= form_section(
+    lang('Podcast.form.location_section_title'),
+    lang('Podcast.form.location_section_subtitle')
+) ?>
+
+<?= form_label(
+    lang('Podcast.form.location_name'),
+    'location_name',
+    [],
+    lang('Podcast.form.location_name_hint'),
+    true
+) ?>
+<?= form_input([
+    'id' => 'location_name',
+    'name' => 'location_name',
+    'class' => 'form-input mb-4',
+    'value' => old('location_name'),
+]) ?>
+<?= form_section_close() ?>
+
 <?= form_section(
     lang('Podcast.form.monetization_section_title'),
     lang('Podcast.form.monetization_section_subtitle')
@@ -250,7 +277,8 @@
     lang('Podcast.form.payment_pointer'),
     'payment_pointer',
     [],
-    lang('Podcast.form.payment_pointer_hint')
+    lang('Podcast.form.payment_pointer_hint'),
+    true
 ) ?>
 <?= form_input([
     'id' => 'payment_pointer',
diff --git a/app/Views/admin/podcast/edit.php b/app/Views/admin/podcast/edit.php
index 2dc632901e..0150c65a9d 100644
--- a/app/Views/admin/podcast/edit.php
+++ b/app/Views/admin/podcast/edit.php
@@ -251,6 +251,26 @@
 
 <?= form_section_close() ?>
 
+<?= form_section(
+    lang('Podcast.form.location_section_title'),
+    lang('Podcast.form.location_section_subtitle')
+) ?>
+
+<?= form_label(
+    lang('Podcast.form.location_name'),
+    'location_name',
+    [],
+    lang('Podcast.form.location_name_hint'),
+    true
+) ?>
+<?= form_input([
+    'id' => 'location_name',
+    'name' => 'location_name',
+    'class' => 'form-input mb-4',
+    'value' => old('location_name', $podcast->location_name),
+]) ?>
+<?= form_section_close() ?>
+
 <?= form_section(
     lang('Podcast.form.monetization_section_title'),
     lang('Podcast.form.monetization_section_subtitle')
@@ -260,7 +280,8 @@
     lang('Podcast.form.payment_pointer'),
     'payment_pointer',
     [],
-    lang('Podcast.form.payment_pointer_hint')
+    lang('Podcast.form.payment_pointer_hint'),
+    true
 ) ?>
 <?= form_input([
     'id' => 'payment_pointer',
diff --git a/app/Views/admin/podcast/view.php b/app/Views/admin/podcast/view.php
index bea7fbc5ad..c21870e4be 100644
--- a/app/Views/admin/podcast/view.php
+++ b/app/Views/admin/podcast/view.php
@@ -6,6 +6,12 @@
 
 <?= $this->section('pageTitle') ?>
 <?= $podcast->title ?>
+<?= location_link(
+    $podcast->location_name,
+    $podcast->location_geo,
+    $podcast->location_osmid,
+    'ml-4'
+) ?>
 <?= $this->endSection() ?>
 
 <?= $this->section('headerRight') ?>
diff --git a/app/Views/episode.php b/app/Views/episode.php
index 2fbcf29a89..6bdf4840b8 100644
--- a/app/Views/episode.php
+++ b/app/Views/episode.php
@@ -100,6 +100,12 @@
                 <?= format_duration($episode->enclosure_duration) ?>
               </time>
           </div>
+          <?= location_link(
+              $episode->location_name,
+              $episode->location_geo,
+              $episode->location_osmid,
+              'self-start mt-2'
+          ) ?>
           <audio controls preload="none" class="w-full mt-auto">
             <source src="<?= $episode->enclosure_web_url ?>" type="<?= $episode->enclosure_type ?>">
             Your browser does not support the audio tag.
diff --git a/app/Views/podcast.php b/app/Views/podcast.php
index 3d013d0d42..eaafbd9fb5 100644
--- a/app/Views/podcast.php
+++ b/app/Views/podcast.php
@@ -49,6 +49,12 @@
                                 lang('Common.explicit') .
                                 '</span>'
                             : '' ?>
+                        <?= location_link(
+                            $podcast->location_name,
+                            $podcast->location_geo,
+                            $podcast->location_osmid,
+                            'ml-4'
+                        ) ?>
                     </div>
                     <div class="flex mb-2 space-x-2">
                         <?= anchor(
-- 
GitLab