diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index 4d0d63009b84d26dd00c90395167dba6d47039a7..f68f50debdf96269a9803ca35714382baf515795 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -49,6 +49,7 @@ class Autoload extends AutoloadConfig 'Modules\Analytics' => ROOTPATH . 'modules/Analytics/', 'Modules\Install' => ROOTPATH . 'modules/Install/', 'Modules\Fediverse' => ROOTPATH . 'modules/Fediverse/', + 'Modules\WebSub' => ROOTPATH . 'modules/WebSub/', 'Config' => APPPATH . 'Config/', 'ViewComponents' => APPPATH . 'Libraries/ViewComponents/', 'ViewThemes' => APPPATH . 'Libraries/ViewThemes/', diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 87a3d717618bff4e1ecd39ab64b4c7b9b7400477..c706585c0b625a45edc9775c317b48b552f82b26 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -69,6 +69,7 @@ use RuntimeException; * @property string|null $location_osm * @property array|null $custom_rss * @property string $custom_rss_string + * @property bool $is_published_on_hubs * @property int $posts_count * @property int $comments_count * @property int $created_by @@ -164,6 +165,7 @@ class Episode extends Entity 'location_geo' => '?string', 'location_osm' => '?string', 'custom_rss' => '?json-array', + 'is_published_on_hubs' => 'boolean', 'posts_count' => 'integer', 'comments_count' => 'integer', 'created_by' => 'integer', diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php index 1b859e2cc2ef8ab448d17f3e5d922ce9c740e122..a54b7f62927f0f78abca4d0d42eaaa078444a825 100644 --- a/app/Entities/Podcast.php +++ b/app/Entities/Podcast.php @@ -73,6 +73,7 @@ use RuntimeException; * @property string|null $payment_pointer * @property array|null $custom_rss * @property string $custom_rss_string + * @property bool $is_published_on_hubs * @property string|null $partner_id * @property string|null $partner_link_url * @property string|null $partner_image_url @@ -180,6 +181,7 @@ class Podcast extends Entity 'location_osm' => '?string', 'payment_pointer' => '?string', 'custom_rss' => '?json-array', + 'is_published_on_hubs' => 'boolean', 'partner_id' => '?string', 'partner_link_url' => '?string', 'partner_image_url' => '?string', diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index 51297b7371d8f8c12ae8f04c2598ec649a9713ae..0a82031c88c0fb1e02539413fc63ce57ccae32b1 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -41,6 +41,16 @@ if (! function_exists('get_rss_feed')) { $atomLink->addAttribute('rel', 'self'); $atomLink->addAttribute('type', 'application/rss+xml'); + // websub: add links to hubs defined in config + $websubHubs = config('WebSub') + ->hubs; + foreach ($websubHubs as $websubHub) { + $atomLinkHub = $channel->addChild('atom:link', null, 'http://www.w3.org/2005/Atom'); + $atomLinkHub->addAttribute('href', $websubHub); + $atomLinkHub->addAttribute('rel', 'hub'); + $atomLinkHub->addAttribute('type', 'application/rss+xml'); + } + if ($podcast->new_feed_url !== null) { $channel->addChild('new-feed-url', $podcast->new_feed_url, $itunesNamespace); } diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index 64fdc7e56301cde9c60817c3b4761e05f82f7321..e9fb463ff4c81216d1d93ee0d11624afcac49ae2 100644 --- a/app/Models/EpisodeModel.php +++ b/app/Models/EpisodeModel.php @@ -81,6 +81,7 @@ class EpisodeModel extends Model 'location_geo', 'location_osm', 'custom_rss', + 'is_published_on_hubs', 'posts_count', 'comments_count', 'published_at', @@ -378,7 +379,7 @@ class EpisodeModel extends Model /** * @param mixed[] $data * - * @return array<string, array<string|int, mixed>> + * @return mixed[] */ public function clearCache(array $data): array { @@ -404,7 +405,7 @@ class EpisodeModel extends Model /** * @param mixed[] $data * - * @return array<string, array<string|int, mixed>> + * @return mixed[] */ protected function writeEnclosureMetadata(array $data): array { diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php index 1a5be2b68ab52dd13b7c92b00c300eb1d1917893..d913fd68a40120e1b6ec2dcc198c6db9bf6c0e57 100644 --- a/app/Models/PodcastModel.php +++ b/app/Models/PodcastModel.php @@ -60,6 +60,7 @@ class PodcastModel extends Model 'location_osm', 'payment_pointer', 'custom_rss', + 'is_published_on_hubs', 'partner_id', 'partner_link_url', 'partner_image_url', diff --git a/crontab b/crontab index b1e03dbb20b368b378a8822a180f266b1dfa6a3a..3589b3e9d90021933c9914761555f0b1b63fa491 100644 --- a/crontab +++ b/crontab @@ -1,2 +1,3 @@ * * * * * /usr/local/bin/php /castopod/public/index.php scheduled-activities * * * * * /usr/local/bin/php /castopod/public/index.php scheduled-video-clips +* * * * * /usr/local/bin/php /castopod/public/index.php scheduled-websub-publish diff --git a/docs/src/getting-started/install.md b/docs/src/getting-started/install.md index 5a62109813b21a035d065c965117bdbd1f1612c2..2a72ada3e7067f2316af0ed89a230b3df262c86d 100644 --- a/docs/src/getting-started/install.md +++ b/docs/src/getting-started/install.md @@ -88,6 +88,13 @@ want to generate Video Clips. The following extensions must be installed: * * * * * /path/to/php /path/to/castopod/public/index.php scheduled-activities ``` + - For having your episodes be broadcasted on open hubs upon publication using + [WebSub](https://en.wikipedia.org/wiki/WebSub): + + ```bash + * * * * * /usr/local/bin/php /castopod/public/index.php scheduled-websub-publish + ``` + - For Video Clips to be created (see [FFmpeg requirements](#ffmpeg-v418-or-higher-for-video-clips)): diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php index 58beff9793c6d803cace05500f0f9ce25f01ad93..84869bfbb87573904c3041e92568fd91546c9d48 100644 --- a/modules/Admin/Controllers/EpisodeController.php +++ b/modules/Admin/Controllers/EpisodeController.php @@ -280,6 +280,9 @@ class EpisodeController extends BaseController $this->episode->setAudio($this->request->getFile('audio_file')); $this->episode->setCover($this->request->getFile('cover')); + // republish on websub hubs upon edit + $this->episode->is_published_on_hubs = false; + $transcriptChoice = $this->request->getPost('transcript-choice'); if ($transcriptChoice === 'upload-file') { $transcriptFile = $this->request->getFile('transcript_file'); @@ -725,6 +728,11 @@ class EpisodeController extends BaseController (new PostModel())->removePost($post); } + // set podcast is_published_on_hubs to false to trigger websub push + (new PodcastModel())->update($this->episode->podcast->id, [ + 'is_published_on_hubs' => false, + ]); + $episodeModel = new EpisodeModel(); if ($this->episode->published_at !== null) { // if episode is published, set episode published_at to null to unpublish before deletion diff --git a/modules/Admin/Controllers/PodcastController.php b/modules/Admin/Controllers/PodcastController.php index ce649a9b46eb3b7b3d3fb8204990f39a43fd4323..8fbbee4d9422081235b3828709877ec60430075b 100644 --- a/modules/Admin/Controllers/PodcastController.php +++ b/modules/Admin/Controllers/PodcastController.php @@ -340,6 +340,9 @@ class PodcastController extends BaseController $this->podcast->is_locked = $this->request->getPost('lock') === 'yes'; $this->podcast->updated_by = (int) user_id(); + // republish on websub hubs upon edit + $this->podcast->is_published_on_hubs = false; + $db = db_connect(); $db->transStart(); diff --git a/modules/Auth/Database/Migrations/2020-07-03-191500_add_podcasts_users.php b/modules/Auth/Database/Migrations/2020-07-03-191500_add_podcasts_users.php index 222eead7338b71657ad8cb5c0d85cc66c18816b7..b6535c4adac24bed0e3f3eb3f21a6f4cf60dab4b 100644 --- a/modules/Auth/Database/Migrations/2020-07-03-191500_add_podcasts_users.php +++ b/modules/Auth/Database/Migrations/2020-07-03-191500_add_podcasts_users.php @@ -10,7 +10,7 @@ declare(strict_types=1); * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Modules\Auth\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/modules/Install/Controllers/InstallController.php b/modules/Install/Controllers/InstallController.php index 49ca7183593f122ae07e2cb91cf7c046669e8ba2..34209a343df4c963aee34d39f42a10ec071c7870 100644 --- a/modules/Install/Controllers/InstallController.php +++ b/modules/Install/Controllers/InstallController.php @@ -251,6 +251,8 @@ class InstallController extends Controller ->latest(); $migrations->setNamespace(APP_NAMESPACE) ->latest(); + $migrations->setNamespace('Modules\WebSub') + ->latest(); $migrations->setNamespace('Modules\Auth') ->latest(); $migrations->setNamespace('Modules\Analytics') diff --git a/modules/WebSub/Config/Routes.php b/modules/WebSub/Config/Routes.php new file mode 100644 index 0000000000000000000000000000000000000000..28ad2cc6c20c34f7fe711c60cc5d29f31077f3a3 --- /dev/null +++ b/modules/WebSub/Config/Routes.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2022 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +$routes = service('routes'); + +/** + * WebSub routes file + */ + +$routes->group('', [ + 'namespace' => 'Modules\WebSub\Controllers', +], function ($routes): void { + $routes->cli('scheduled-websub-publish', 'WebSubController::publish'); +}); diff --git a/modules/WebSub/Config/WebSub.php b/modules/WebSub/Config/WebSub.php new file mode 100644 index 0000000000000000000000000000000000000000..745a0eb1eeee0a1c9c45476d6b7dd16abf0db4d9 --- /dev/null +++ b/modules/WebSub/Config/WebSub.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +namespace Modules\WebSub\Config; + +use CodeIgniter\Config\BaseConfig; + +class WebSub extends BaseConfig +{ + /** + * -------------------------------------------------------------------------- + * Hubs to ping + * -------------------------------------------------------------------------- + * @var string[] + */ + public array $hubs = [ + 'https://pubsubhubbub.appspot.com/', + 'https://pubsubhubbub.superfeedr.com/', + 'https://websubhub.com/hub', + 'https://switchboard.p3k.io/', + ]; +} diff --git a/modules/WebSub/Controllers/WebSubController.php b/modules/WebSub/Controllers/WebSubController.php new file mode 100644 index 0000000000000000000000000000000000000000..a75581720ab655dadaca5695f30b053897e359e9 --- /dev/null +++ b/modules/WebSub/Controllers/WebSubController.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2022 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\WebSub\Controllers; + +use App\Models\EpisodeModel; +use App\Models\PodcastModel; +use CodeIgniter\Controller; +use Config\Services; +use Exception; + +class WebSubController extends Controller +{ + public function publish(): void + { + if (ENVIRONMENT !== 'production') { + return; + } + + // get all podcasts that haven't been published yet + // or having a published episode that hasn't been pushed yet + $podcastModel = new PodcastModel(); + $podcasts = $podcastModel + ->distinct() + ->select('podcasts.*') + ->join('episodes', 'podcasts.id = episodes.podcast_id', 'left outer') + ->where('podcasts.is_published_on_hubs', false) + ->orGroupStart() + ->where('episodes.is_published_on_hubs', false) + ->where('`' . $podcastModel->db->getPrefix() . 'episodes`.`published_at` <= NOW()', null, false) + ->groupEnd() + ->findAll(); + + if ($podcasts === []) { + return; + } + + $request = Services::curlrequest(); + + $requestOptions = [ + 'headers' => [ + 'User-Agent' => 'Castopod/' . CP_VERSION . '; +' . base_url('', 'https'), + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + ]; + + $hubUrls = config('WebSub') + ->hubs; + + foreach ($podcasts as $podcast) { + $requestOptions['form_params'] = [ + 'hub.mode' => 'publish', + 'hub.url' => $podcast->feed_url, + ]; + + foreach ($hubUrls as $hub) { + try { + $request->post($hub, $requestOptions); + } catch (Exception $exception) { + log_message( + 'critical', + "COULD NOT PUBLISH @{$podcast->handle} ON {$hub}" . PHP_EOL . $exception->getMessage() + ); + } + } + + // set podcast feed as having been pushed onto hubs + (new PodcastModel())->update($podcast->id, [ + 'is_published_on_hubs' => true, + ]); + + // set newly published episodes as pushed onto hubs + (new EpisodeModel())->set('is_published_on_hubs', true) + ->where([ + 'podcast_id' => $podcast->id, + 'is_published_on_hubs' => false, + ]) + ->where('`published_at` <= NOW()', null, false) + ->update(); + } + } +} diff --git a/modules/WebSub/Database/Migrations/2022-03-07-180000_add_is_published_on_hubs_to_podcasts.php b/modules/WebSub/Database/Migrations/2022-03-07-180000_add_is_published_on_hubs_to_podcasts.php new file mode 100644 index 0000000000000000000000000000000000000000..523ffdfdfd445852d7724f9949eee41688240d03 --- /dev/null +++ b/modules/WebSub/Database/Migrations/2022-03-07-180000_add_is_published_on_hubs_to_podcasts.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2022 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\WebSub\Database\Migrations; + +use CodeIgniter\Database\Migration; + +class AddIsPublishedOnHubsToPodcasts extends Migration +{ + public function up(): void + { + $prefix = $this->db->getPrefix(); + + $createQuery = <<<CODE_SAMPLE + ALTER TABLE {$prefix}podcasts + ADD COLUMN `is_published_on_hubs` BOOLEAN NOT NULL DEFAULT 0 AFTER `custom_rss`; + CODE_SAMPLE; + + $this->db->query($createQuery); + } + + public function down(): void + { + $prefix = $this->db->getPrefix(); + + $this->forge->dropColumn($prefix . 'podcasts', 'is_published_on_hubs'); + } +} diff --git a/modules/WebSub/Database/Migrations/2022-03-07-181500_add_is_published_on_hubs_to_episodes.php b/modules/WebSub/Database/Migrations/2022-03-07-181500_add_is_published_on_hubs_to_episodes.php new file mode 100644 index 0000000000000000000000000000000000000000..3bb719661ddfd77ac6858f7ae36d35b85f0f3818 --- /dev/null +++ b/modules/WebSub/Database/Migrations/2022-03-07-181500_add_is_published_on_hubs_to_episodes.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2022 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\WebSub\Database\Migrations; + +use CodeIgniter\Database\Migration; + +class AddIsPublishedOnHubsToEpisodes extends Migration +{ + public function up(): void + { + $prefix = $this->db->getPrefix(); + + $createQuery = <<<CODE_SAMPLE + ALTER TABLE {$prefix}episodes + ADD COLUMN `is_published_on_hubs` BOOLEAN NOT NULL DEFAULT 0 AFTER `custom_rss`; + CODE_SAMPLE; + + $this->db->query($createQuery); + } + + public function down(): void + { + $prefix = $this->db->getPrefix(); + + $this->forge->dropColumn($prefix . 'episodes', 'is_published_on_hubs'); + } +}