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