From 5eb9dc168eb9af04767829b76242c9120f55d46d Mon Sep 17 00:00:00 2001
From: root <ola@castopod.org>
Date: Wed, 15 Jun 2022 10:27:13 +0000
Subject: [PATCH] feat: add update rss feed feature for podcasts to import
 their latest episodes

closes #183
---
 modules/Admin/Config/Routes.php               |   4 +
 .../Controllers/PodcastImportController.php   | 224 ++++++++++++++++++
 modules/Admin/Language/en/Podcast.php         |   9 +
 themes/cp_admin/podcast/edit.php              |   8 +
 4 files changed, 245 insertions(+)

diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php
index 64c394f0be..4bde002ef7 100644
--- a/modules/Admin/Config/Routes.php
+++ b/modules/Admin/Config/Routes.php
@@ -130,6 +130,10 @@ $routes->group(
                 $routes->post('delete', 'PodcastController::attemptDelete/$1', [
                     'filter' => 'permission:podcasts-delete',
                 ]);
+                $routes->get('update', 'PodcastImportController::updateImport/$1', [
+                    'as' => 'podcast-update-feed',
+                    'filter' => 'permission:podcasts-import',
+                ]);
 
                 $routes->group('persons', function ($routes): void {
                     $routes->get('/', 'PodcastPersonController/$1', [
diff --git a/modules/Admin/Controllers/PodcastImportController.php b/modules/Admin/Controllers/PodcastImportController.php
index 0f5c9d1c7f..b200910a19 100644
--- a/modules/Admin/Controllers/PodcastImportController.php
+++ b/modules/Admin/Controllers/PodcastImportController.php
@@ -460,4 +460,228 @@ class PodcastImportController extends BaseController
 
         return redirect()->route('podcast-view', [$newPodcastId]);
     }
+
+    public function updateImport(): RedirectResponse
+    {
+        if ($this->podcast->imported_feed_url === null) {
+            return redirect()
+                ->back()
+                ->with('error', lang('Podcast.messages.podcastNotImported'));
+        }
+
+        try {
+            ini_set('user_agent', 'Castopod/' . CP_VERSION);
+            $feed = simplexml_load_file($this->podcast->imported_feed_url);
+        } catch (ErrorException $errorException) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', [
+                    $errorException->getMessage() .
+                    ': <a href="' .
+                    $this->podcast->imported_feed_url .
+                    '" rel="noreferrer noopener" target="_blank">' .
+                    $this->podcast->imported_feed_url .
+                    ' ⎋</a>',
+                ]);
+        }
+
+        $nsPodcast = $feed->channel[0]->children(
+            'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md',
+        );
+
+        if ((string) $nsPodcast->locked === 'yes') {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', [lang('PodcastImport.lock_import')]);
+        }
+
+        $itemsCount = $feed->channel[0]->item->count();
+
+        $lastItem = $itemsCount;
+
+        $lastEpisode = (new EpisodeModel())->where('podcast_id', $this->podcast->id)
+            ->orderBy('created_at', 'desc')
+            ->first();
+
+        if ($lastEpisode !== null) {
+            for ($itemNumber = 0; $itemNumber < $itemsCount; ++$itemNumber) {
+                $item = $feed->channel[0]->item[$itemNumber];
+
+                if (property_exists(
+                    $item,
+                    'guid'
+                ) && $item->guid !== null && $lastEpisode->guid === (string) $item->guid) {
+                    $lastItem = $itemNumber;
+                    break;
+                }
+            }
+        }
+
+        if ($lastItem === 0) {
+            return redirect()
+                ->back()
+                ->with('message', lang('Podcast.messages.podcastFeedUpToDate'));
+        }
+
+        helper(['media', 'misc']);
+
+        $converter = new HtmlConverter();
+
+        $slugs = [];
+
+        $db = db_connect();
+        $db->transStart();
+
+        for ($itemNumber = 1; $itemNumber <= $lastItem; ++$itemNumber) {
+            $item = $feed->channel[0]->item[$lastItem - $itemNumber];
+
+            $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',
+            );
+
+            $textToSlugify = (string) $item->title;
+            $slug = slugify($textToSlugify, 120);
+            if (in_array($slug, $slugs, true) || (new EpisodeModel())->where([
+                'slug' => $slug,
+                'podcast_id' => $this->podcast->id,
+            ])->first()) {
+                $slugNumber = 2;
+                while (in_array($slug . '-' . $slugNumber, $slugs, true) || (new EpisodeModel())->where([
+                    'slug' => $slug . '-' . $slugNumber,
+                    'podcast_id' => $this->podcast->id,
+                ])->first()) {
+                    ++$slugNumber;
+                }
+
+                $slug = $slug . '-' . $slugNumber;
+            }
+
+            $slugs[] = $slug;
+
+            $itemDescriptionHtml = (string) $item->description;
+
+            if (
+                property_exists($nsItunes, 'image') && $nsItunes->image !== null &&
+                $nsItunes->image->attributes()['href'] !== null
+            ) {
+                $episodeCover = download_file((string) $nsItunes->image->attributes()['href']);
+            } else {
+                $episodeCover = null;
+            }
+
+            $location = null;
+            if (property_exists($nsPodcast, 'location') && $nsPodcast->location !== null) {
+                $location = new Location(
+                    (string) $nsPodcast->location,
+                    $nsPodcast->location->attributes()['geo'] === null ? null : (string) $nsPodcast->location->attributes()['geo'],
+                    $nsPodcast->location->attributes()['osm'] === null ? null : (string) $nsPodcast->location->attributes()['osm'],
+                );
+            }
+
+            $newEpisode = new Episode([
+                'podcast_id' => $this->podcast->id,
+                'title' => $item->title,
+                'slug' => $slug,
+                'guid' => $item->guid ?? null,
+                'audio' => download_file(
+                    (string) $item->enclosure->attributes()['url'],
+                    (string) $item->enclosure->attributes()['type']
+                ),
+                'description_markdown' => $converter->convert($itemDescriptionHtml),
+                'description_html' => $itemDescriptionHtml,
+                'cover' => $episodeCover,
+                'parental_advisory' =>
+                property_exists($nsItunes, 'explicit') && $nsItunes->explicit !== null
+                ? (in_array((string) $nsItunes->explicit, ['yes', 'true'], true)
+                ? 'explicit'
+                : (in_array((string) $nsItunes->explicit, ['no', 'false'], true)
+                ? 'clean'
+                : null))
+                : null,
+                'number' => ((string) $nsItunes->episode === '' ? null : (int) $nsItunes->episode),
+                'season_number' => ((string) $nsItunes->season === '' ? null : (int) $nsItunes->season),
+                'type' => property_exists($nsItunes, 'episodeType') && $nsItunes->episodeType !== null
+                ? (string) $nsItunes->episodeType
+                : 'full',
+                'is_blocked' => property_exists(
+                    $nsItunes,
+                    'block'
+                ) && $nsItunes->block !== null && (string) $nsItunes->block === 'yes',
+                'location' => $location,
+                'created_by' => user_id(),
+                'updated_by' => user_id(),
+                'published_at' => strtotime((string) $item->pubDate),
+            ]);
+
+            $episodeModel = new EpisodeModel();
+
+            if (! ($newEpisodeId = $episodeModel->insert($newEpisode, true))) {
+                // FIXME: What shall we do?
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $episodeModel->errors());
+            }
+
+            foreach ($nsPodcast->person as $episodePerson) {
+                $fullName = (string) $episodePerson;
+                $personModel = new PersonModel();
+                $newPersonId = null;
+                if (($newPerson = $personModel->getPerson($fullName)) !== null) {
+                    $newPersonId = $newPerson->id;
+                } else {
+                    $newPerson = new Person([
+                        'full_name' => $fullName,
+                        'unique_name' => slugify($fullName),
+                        'information_url' => $episodePerson->attributes()['href'],
+                        'avatar' => download_file((string) $episodePerson->attributes()['img']),
+                        'created_by' => user_id(),
+                        'updated_by' => user_id(),
+                    ]);
+
+                    if (! ($newPersonId = $personModel->insert($newPerson))) {
+                        return redirect()
+                            ->back()
+                            ->withInput()
+                            ->with('errors', $personModel->errors());
+                    }
+                }
+
+                // 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(
+                    $this->podcast->id,
+                    $newEpisodeId,
+                    $newPersonId,
+                    $personGroupSlug,
+                    $personRoleSlug
+                )) {
+                    return redirect()
+                        ->back()
+                        ->withInput()
+                        ->with('errors', $episodePersonModel->errors());
+                }
+            }
+        }
+
+        $db->transComplete();
+
+        return redirect()->route('podcast-view', [$this->podcast->id])->with(
+            'message',
+            lang('Podcast.messages.podcastFeedUpdateSuccess', [
+                'number_of_new_episodes' => $lastItem,
+            ])
+        );
+    }
 }
diff --git a/modules/Admin/Language/en/Podcast.php b/modules/Admin/Language/en/Podcast.php
index eb0f30f19c..0e9ba7e9f0 100644
--- a/modules/Admin/Language/en/Podcast.php
+++ b/modules/Admin/Language/en/Podcast.php
@@ -40,6 +40,12 @@ return [
             other {media}
         }.',
         'deletePodcastMediaFolderError' => 'Failed to delete podcast media folder {folder_path}. You may manually remove it from your disk.',
+        'podcastFeedUpdateSuccess' => 'Successful update : {number_of_new_episodes, plural,
+            one {# episode was}
+            other {# episodes were}
+        } added to the podcast!',
+        'podcastFeedUpToDate' => 'This podcast is up to date.',
+        'podcastNotImported' => 'This podcast could not be updated as it was not imported.',
     ],
     'form' => [
         'identity_section_title' => 'Podcast identity',
@@ -104,6 +110,9 @@ return [
         'custom_rss_hint' => 'This will be injected within the ❬channel❭ tag.',
         'new_feed_url' => 'New feed URL',
         'new_feed_url_hint' => 'Use this field when you move to another domain or podcast hosting platform. By default, the value is set to the current RSS URL if the podcast is imported.',
+        'old_feed_url' => 'Old feed URL',
+        'update_feed' => 'Update feed',
+        'update_feed_tip' => 'Import this podcast\'s latest episodes',
         'partnership' => 'Partnership',
         'partner_id' => 'ID',
         'partner_link_url' => 'Link URL',
diff --git a/themes/cp_admin/podcast/edit.php b/themes/cp_admin/podcast/edit.php
index ef31830e9e..0fa9ae27c1 100644
--- a/themes/cp_admin/podcast/edit.php
+++ b/themes/cp_admin/podcast/edit.php
@@ -229,6 +229,14 @@
     value="<?= esc($podcast->new_feed_url) ?>"
 />
 
+<?php if ($podcast->imported_feed_url !== null): ?>
+    <div class="flex flex-col">
+        <Forms.Label for="old_feed_url"><?= lang('Podcast.form.old_feed_url') ?></Forms.Label>
+        <Forms.Input name="old_feed_url" readonly="true" value="<?= esc($podcast->imported_feed_url) ?>" />
+    </div>
+    <Button variant="primary" class="self-end" uri="<?= route_to('podcast-update-feed', $podcast->id) ?>" iconLeft="refresh" data-tooltip="bottom" title="<?= lang('Podcast.form.update_feed_tip') ?>"><?= lang('Podcast.form.update_feed') ?></Button>
+<?php endif ?>  
+    
 </Forms.Section>
 
 <Forms.Section
-- 
GitLab