From 9e1e5d2e862d6a3345d11ca7f96b955c76bfa013 Mon Sep 17 00:00:00 2001 From: Yassine Doghri Date: Mon, 12 Jul 2021 18:40:22 +0000 Subject: [PATCH] feat(activitypub): add Podcast actor and PodcastEpisode object with comments --- app/Config/ActivityPub.php | 3 - app/Config/Routes.php | 38 ++++++++ app/Controllers/EpisodeController.php | 61 +++++++++++++ app/Controllers/PodcastController.php | 57 ++++++++++++ app/Entities/Episode.php | 23 ++++- .../Controllers/StatusController.php | 2 +- .../ActivityPub/Objects/NoteObject.php | 2 +- .../Objects/OrderedCollectionObject.php | 2 +- app/Libraries/Analytics/AnalyticsTrait.php | 2 +- app/Libraries/PodcastActor.php | 34 +++++-- app/Libraries/PodcastEpisode.php | 89 +++++++++++++++++++ app/Models/StatusModel.php | 18 ++++ app/Views/admin/podcast/latest_episodes.php | 2 +- 13 files changed, 316 insertions(+), 17 deletions(-) create mode 100644 app/Libraries/PodcastEpisode.php diff --git a/app/Config/ActivityPub.php b/app/Config/ActivityPub.php index 00bb6482..45e1f1a5 100644 --- a/app/Config/ActivityPub.php +++ b/app/Config/ActivityPub.php @@ -6,7 +6,6 @@ namespace Config; use ActivityPub\Config\ActivityPub as ActivityPubBase; use App\Libraries\NoteObject; -use App\Libraries\PodcastActor; class ActivityPub extends ActivityPubBase { @@ -15,8 +14,6 @@ class ActivityPub extends ActivityPubBase * ActivityPub Objects * -------------------------------------------------------------------- */ - public string $actorObject = PodcastActor::class; - public string $noteObject = NoteObject::class; /** diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 41f8be75..cb5dcf7a 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -697,6 +697,10 @@ $routes->group('@(:podcastName)', function ($routes): void { 'namespace' => 'ActivityPub\Controllers', 'controller-method' => 'ActorController/$1', ], + 'application/podcast-activity+json' => [ + 'namespace' => 'App\Controllers', + 'controller-method' => 'PodcastController::podcastActor/$1', + ], 'application/ld+json; profile="https://www.w3.org/ns/activitystreams' => [ 'namespace' => 'ActivityPub\Controllers', 'controller-method' => 'ActorController/$1', @@ -705,10 +709,44 @@ $routes->group('@(:podcastName)', function ($routes): void { ]); $routes->get('episodes', 'PodcastController::episodes/$1', [ 'as' => 'podcast-episodes', + 'alternate-content' => [ + 'application/activity+json' => [ + 'controller-method' => 'PodcastController::episodeCollection/$1', + ], + 'application/podcast-activity+json' => [ + 'controller-method' => 'PodcastController::episodeCollection/$1', + ], + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams' => [ + 'controller-method' => 'PodcastController::episodeCollection/$1', + ], + ], ]); $routes->group('episodes/(:slug)', function ($routes): void { $routes->get('/', 'EpisodeController/$1/$2', [ 'as' => 'episode', + 'alternate-content' => [ + 'application/activity+json' => [ + 'controller-method' => 'EpisodeController::episodeObject/$1/$2', + ], + 'application/podcast-activity+json' => [ + 'controller-method' => 'EpisodeController::episodeObject/$1/$2', + ], + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams' => [ + 'controller-method' => 'EpisodeController::episodeObject/$1/$2', + ], + ], + ]); + $routes->get('comments', 'EpisodeController::comments/$1/$2', [ + 'as' => 'episode-comments', + 'application/activity+json' => [ + 'controller-method' => 'EpisodeController::comments/$1/$2', + ], + 'application/podcast-activity+json' => [ + 'controller-method' => 'EpisodeController::comments/$1/$2', + ], + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams' => [ + 'controller-method' => 'EpisodeController::comments/$1/$2', + ], ]); $routes->get('oembed.json', 'EpisodeController::oembedJSON/$1/$2', [ 'as' => 'episode-oembed-json', diff --git a/app/Controllers/EpisodeController.php b/app/Controllers/EpisodeController.php index 89c6fac2..34922690 100644 --- a/app/Controllers/EpisodeController.php +++ b/app/Controllers/EpisodeController.php @@ -10,12 +10,18 @@ declare(strict_types=1); namespace App\Controllers; +use ActivityPub\Objects\OrderedCollectionObject; +use ActivityPub\Objects\OrderedCollectionPage; use Analytics\AnalyticsTrait; use App\Entities\Episode; use App\Entities\Podcast; +use App\Libraries\NoteObject; +use App\Libraries\PodcastEpisode; use App\Models\EpisodeModel; use App\Models\PodcastModel; +use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\ResponseInterface; use Config\Services; use SimpleXMLElement; @@ -191,4 +197,59 @@ class EpisodeController extends BaseController return $this->response->setXML((string) $oembed); } + + /** + * @noRector ReturnTypeDeclarationRector + */ + public function episodeObject(): Response + { + $podcastObject = new PodcastEpisode($this->episode); + + return $this->response + ->setContentType('application/json') + ->setBody($podcastObject->toJSON()); + } + + /** + * @noRector ReturnTypeDeclarationRector + */ + public function comments(): Response + { + /** + * get comments: aggregated replies from posts referring to the episode + */ + $episodeComments = model('StatusModel') + ->whereIn('in_reply_to_id', function (BaseBuilder $builder): BaseBuilder { + return $builder->select('id') + ->from('activitypub_statuses') + ->where('episode_id', $this->episode->id); + }) + ->where('`published_at` <= NOW()', null, false) + ->orderBy('published_at', 'ASC'); + + $pageNumber = (int) $this->request->getGet('page'); + + if ($pageNumber < 1) { + $episodeComments->paginate(12); + $pager = $episodeComments->pager; + $collection = new OrderedCollectionObject(null, $pager); + } else { + $paginatedComments = $episodeComments->paginate(12, 'default', $pageNumber); + $pager = $episodeComments->pager; + + $orderedItems = []; + if ($paginatedComments !== null) { + foreach ($paginatedComments as $comment) { + $orderedItems[] = (new NoteObject($comment))->toArray(); + } + } + + // @phpstan-ignore-next-line + $collection = new OrderedCollectionPage($pager, $orderedItems); + } + + return $this->response + ->setContentType('application/activity+json') + ->setBody($collection->toJSON()); + } } diff --git a/app/Controllers/PodcastController.php b/app/Controllers/PodcastController.php index c8702746..75d50111 100644 --- a/app/Controllers/PodcastController.php +++ b/app/Controllers/PodcastController.php @@ -10,12 +10,18 @@ declare(strict_types=1); namespace App\Controllers; +use ActivityPub\Objects\OrderedCollectionObject; +use ActivityPub\Objects\OrderedCollectionPage; use Analytics\AnalyticsTrait; use App\Entities\Podcast; +use App\Libraries\PodcastActor; +use App\Libraries\PodcastEpisode; use App\Models\EpisodeModel; use App\Models\PodcastModel; use App\Models\StatusModel; use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\HTTP\Response; class PodcastController extends BaseController { @@ -42,6 +48,15 @@ class PodcastController extends BaseController return $this->{$method}(...$params); } + public function podcastActor(): RedirectResponse + { + $podcastActor = new PodcastActor($this->podcast); + + return $this->response + ->setContentType('application/activity+json') + ->setBody($podcastActor->toJSON()); + } + public function activity(): string { // Prevent analytics hit when authenticated @@ -209,4 +224,46 @@ class PodcastController extends BaseController return $cachedView; } + + /** + * @noRector ReturnTypeDeclarationRector + */ + public function episodeCollection(): Response + { + if ($this->podcast->type === 'serial') { + // podcast is serial + $episodes = model('EpisodeModel') + ->where('`published_at` <= NOW()', null, false) + ->orderBy('season_number DESC, number ASC'); + } else { + $episodes = model('EpisodeModel') + ->where('`published_at` <= NOW()', null, false) + ->orderBy('published_at', 'DESC'); + } + + $pageNumber = (int) $this->request->getGet('page'); + + if ($pageNumber < 1) { + $episodes->paginate(12); + $pager = $episodes->pager; + $collection = new OrderedCollectionObject(null, $pager); + } else { + $paginatedEpisodes = $episodes->paginate(12, 'default', $pageNumber); + $pager = $episodes->pager; + + $orderedItems = []; + if ($paginatedEpisodes !== null) { + foreach ($paginatedEpisodes as $episode) { + $orderedItems[] = (new PodcastEpisode($episode))->toArray(); + } + } + + // @phpstan-ignore-next-line + $collection = new OrderedCollectionPage($pager, $orderedItems); + } + + return $this->response + ->setContentType('application/activity+json') + ->setBody($collection->toJSON()); + } } diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index c10cbb17..1e22dd36 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -121,6 +121,11 @@ class Episode extends Entity */ protected ?array $statuses = null; + /** + * @var Status[]|null + */ + protected ?array $comments = null; + protected ?Location $location = null; protected string $custom_rss_string; @@ -387,7 +392,7 @@ class Episode extends Entity public function getStatuses(): array { if ($this->id === null) { - throw new RuntimeException('Episode must be created before getting soundbites.'); + throw new RuntimeException('Episode must be created before getting statuses.'); } if ($this->statuses === null) { @@ -397,6 +402,22 @@ class Episode extends Entity return $this->statuses; } + /** + * @return Status[] + */ + public function getComments(): array + { + if ($this->id === null) { + throw new RuntimeException('Episode must be created before getting comments.'); + } + + if ($this->comments === null) { + $this->comments = (new StatusModel())->getEpisodeComments($this->id); + } + + return $this->comments; + } + public function getLink(): string { return base_url(route_to('episode', $this->getPodcast() ->name, $this->attributes['slug'])); diff --git a/app/Libraries/ActivityPub/Controllers/StatusController.php b/app/Libraries/ActivityPub/Controllers/StatusController.php index e43b433e..022304f2 100644 --- a/app/Libraries/ActivityPub/Controllers/StatusController.php +++ b/app/Libraries/ActivityPub/Controllers/StatusController.php @@ -92,7 +92,7 @@ class StatusController extends Controller if ($paginatedReplies !== null) { foreach ($paginatedReplies as $reply) { $replyObject = new $noteObjectClass($reply); - $orderedItems[] = $replyObject->toJSON(); + $orderedItems[] = $replyObject->toArray(); } } diff --git a/app/Libraries/ActivityPub/Objects/NoteObject.php b/app/Libraries/ActivityPub/Objects/NoteObject.php index b44d6709..067eef84 100644 --- a/app/Libraries/ActivityPub/Objects/NoteObject.php +++ b/app/Libraries/ActivityPub/Objects/NoteObject.php @@ -39,7 +39,7 @@ class NoteObject extends ObjectType $this->inReplyTo = $status->reply_to_status->uri; } - $this->replies = base_url(route_to('status-replies', $status->actor->username, $status->id)); + $this->replies = url_to('status-replies', $status->actor->username, $status->id); $this->cc = [$status->actor->followers_url]; } diff --git a/app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php b/app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php index 1b2d2888..64e64c69 100644 --- a/app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php +++ b/app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php @@ -28,7 +28,7 @@ class OrderedCollectionObject extends ObjectType protected ?string $last = null; /** - * @param ObjectType[] $orderedItems + * @param ObjectType[]|null $orderedItems */ public function __construct( protected ?array $orderedItems = null, diff --git a/app/Libraries/Analytics/AnalyticsTrait.php b/app/Libraries/Analytics/AnalyticsTrait.php index 18fa9add..87c9d0d5 100644 --- a/app/Libraries/Analytics/AnalyticsTrait.php +++ b/app/Libraries/Analytics/AnalyticsTrait.php @@ -40,7 +40,7 @@ trait AnalyticsTrait $procedureName = $db->prefixTable('analytics_website'); $db->query("call {$procedureName}(?,?,?,?,?,?)", [ $podcastId, - $session->get('browser'), + $session->get('browser') ?? '', $session->get('entryPage'), $referer, $domain, diff --git a/app/Libraries/PodcastActor.php b/app/Libraries/PodcastActor.php index bde17369..3eef392d 100644 --- a/app/Libraries/PodcastActor.php +++ b/app/Libraries/PodcastActor.php @@ -10,21 +10,39 @@ declare(strict_types=1); namespace App\Libraries; -use ActivityPub\Entities\Actor; use ActivityPub\Objects\ActorObject; -use App\Models\PodcastModel; +use App\Entities\Podcast; class PodcastActor extends ActorObject { - protected string $rss; + protected string $rssFeed; - public function __construct(Actor $actor) + protected string $language; + + protected string $category; + + protected string $episodes; + + public function __construct(Podcast $podcast) { - parent::__construct($actor); + parent::__construct($podcast->actor); - $podcast = (new PodcastModel())->where('actor_id', $actor->id) - ->first(); + $this->context[] = 'https://github.com/Podcastindex-org/activitypub-spec-work/blob/main/docs/1.0.md'; - $this->rss = $podcast->feed_url; + $this->type = 'Podcast'; + + $this->rssFeed = $podcast->feed_url; + + $this->language = $podcast->language_code; + + $category = ''; + if ($podcast->category->parent_id !== null) { + $category .= $podcast->category->parent->apple_category . ' > '; + } + $category .= $podcast->category->apple_category; + + $this->category = $category; + + $this->episodes = url_to('podcast-episodes', $podcast->name); } } diff --git a/app/Libraries/PodcastEpisode.php b/app/Libraries/PodcastEpisode.php new file mode 100644 index 00000000..9a8a2002 --- /dev/null +++ b/app/Libraries/PodcastEpisode.php @@ -0,0 +1,89 @@ + + */ + protected array $description = []; + + /** + * @var array + */ + protected array $image = []; + + /** + * @var array + */ + protected array $audio = []; + + public function __construct(Episode $episode) + { + // TODO: clean things up with specified spec + $this->id = $episode->link; + + $this->description = [ + 'type' => 'Note', + 'mediaType' => 'text/markdown', + 'content' => $episode->description_markdown, + 'contentMap' => [ + $episode->podcast->language_code => $episode->description_html, + ], + ]; + + $this->image = [ + 'type' => 'Image', + 'mediaType' => $episode->image_mimetype, + 'url' => $episode->image->url, + ]; + + // add audio file + $this->audio = [ + 'id' => $episode->audio_file_url, + 'type' => 'Audio', + 'name' => $episode->title, + 'size' => $episode->audio_file_size, + 'duration' => $episode->audio_file_duration, + 'url' => [ + 'href' => $episode->audio_file_url, + 'type' => 'Link', + 'mediaType' => $episode->audio_file_mimetype, + ], + 'transcript' => $episode->transcript_file_url, + 'chapters' => $episode->chapters_file_url, + ]; + + $this->comments = url_to('episode-comments', $episode->podcast->name, $episode->slug); + + if ($episode->published_at !== null) { + $this->published = $episode->published_at->format(DATE_W3C); + } + + if ($episode->podcast->actor !== null) { + $this->attributedTo = $episode->podcast->actor->uri; + + if ($episode->podcast->actor->followers_url) { + $this->cc = [$episode->podcast->actor->followers_url]; + } + } + } +} diff --git a/app/Models/StatusModel.php b/app/Models/StatusModel.php index 08ba7275..132c48ef 100644 --- a/app/Models/StatusModel.php +++ b/app/Models/StatusModel.php @@ -12,6 +12,7 @@ namespace App\Models; use ActivityPub\Models\StatusModel as ActivityPubStatusModel; use App\Entities\Status; +use CodeIgniter\Database\BaseBuilder; class StatusModel extends ActivityPubStatusModel { @@ -53,4 +54,21 @@ class StatusModel extends ActivityPubStatusModel ->orderBy('published_at', 'DESC') ->findAll(); } + + /** + * Retrieves all published statuses for a given episode ordered by publication date + * + * @return Status[] + */ + public function getEpisodeComments(int $episodeId): array + { + return $this->whereIn('in_reply_to_id', function (BaseBuilder $builder) use (&$episodeId): BaseBuilder { + return $builder->select('id') + ->from('activitypub_statuses') + ->where('episode_id', $episodeId); + }) + ->where('`published_at` <= NOW()', null, false) + ->orderBy('published_at', 'ASC') + ->findAll(); + } } diff --git a/app/Views/admin/podcast/latest_episodes.php b/app/Views/admin/podcast/latest_episodes.php index 4bf5ac7d..b36ba213 100644 --- a/app/Views/admin/podcast/latest_episodes.php +++ b/app/Views/admin/podcast/latest_episodes.php @@ -10,7 +10,7 @@ -
+