Commit 9e1e5d2e authored by Yassine Doghri's avatar Yassine Doghri
Browse files

feat(activitypub): add Podcast actor and PodcastEpisode object with comments

parent b814cfaf
Pipeline #974 passed with stages
in 9 minutes and 1 second
...@@ -6,7 +6,6 @@ namespace Config; ...@@ -6,7 +6,6 @@ namespace Config;
use ActivityPub\Config\ActivityPub as ActivityPubBase; use ActivityPub\Config\ActivityPub as ActivityPubBase;
use App\Libraries\NoteObject; use App\Libraries\NoteObject;
use App\Libraries\PodcastActor;
class ActivityPub extends ActivityPubBase class ActivityPub extends ActivityPubBase
{ {
...@@ -15,8 +14,6 @@ class ActivityPub extends ActivityPubBase ...@@ -15,8 +14,6 @@ class ActivityPub extends ActivityPubBase
* ActivityPub Objects * ActivityPub Objects
* -------------------------------------------------------------------- * --------------------------------------------------------------------
*/ */
public string $actorObject = PodcastActor::class;
public string $noteObject = NoteObject::class; public string $noteObject = NoteObject::class;
/** /**
......
...@@ -697,6 +697,10 @@ $routes->group('@(:podcastName)', function ($routes): void { ...@@ -697,6 +697,10 @@ $routes->group('@(:podcastName)', function ($routes): void {
'namespace' => 'ActivityPub\Controllers', 'namespace' => 'ActivityPub\Controllers',
'controller-method' => 'ActorController/$1', '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' => [ 'application/ld+json; profile="https://www.w3.org/ns/activitystreams' => [
'namespace' => 'ActivityPub\Controllers', 'namespace' => 'ActivityPub\Controllers',
'controller-method' => 'ActorController/$1', 'controller-method' => 'ActorController/$1',
...@@ -705,10 +709,44 @@ $routes->group('@(:podcastName)', function ($routes): void { ...@@ -705,10 +709,44 @@ $routes->group('@(:podcastName)', function ($routes): void {
]); ]);
$routes->get('episodes', 'PodcastController::episodes/$1', [ $routes->get('episodes', 'PodcastController::episodes/$1', [
'as' => 'podcast-episodes', '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->group('episodes/(:slug)', function ($routes): void {
$routes->get('/', 'EpisodeController/$1/$2', [ $routes->get('/', 'EpisodeController/$1/$2', [
'as' => 'episode', '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', [ $routes->get('oembed.json', 'EpisodeController::oembedJSON/$1/$2', [
'as' => 'episode-oembed-json', 'as' => 'episode-oembed-json',
......
...@@ -10,12 +10,18 @@ declare(strict_types=1); ...@@ -10,12 +10,18 @@ declare(strict_types=1);
namespace App\Controllers; namespace App\Controllers;
use ActivityPub\Objects\OrderedCollectionObject;
use ActivityPub\Objects\OrderedCollectionPage;
use Analytics\AnalyticsTrait; use Analytics\AnalyticsTrait;
use App\Entities\Episode; use App\Entities\Episode;
use App\Entities\Podcast; use App\Entities\Podcast;
use App\Libraries\NoteObject;
use App\Libraries\PodcastEpisode;
use App\Models\EpisodeModel; use App\Models\EpisodeModel;
use App\Models\PodcastModel; use App\Models\PodcastModel;
use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\ResponseInterface;
use Config\Services; use Config\Services;
use SimpleXMLElement; use SimpleXMLElement;
...@@ -191,4 +197,59 @@ class EpisodeController extends BaseController ...@@ -191,4 +197,59 @@ class EpisodeController extends BaseController
return $this->response->setXML((string) $oembed); 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());
}
} }
...@@ -10,12 +10,18 @@ declare(strict_types=1); ...@@ -10,12 +10,18 @@ declare(strict_types=1);
namespace App\Controllers; namespace App\Controllers;
use ActivityPub\Objects\OrderedCollectionObject;
use ActivityPub\Objects\OrderedCollectionPage;
use Analytics\AnalyticsTrait; use Analytics\AnalyticsTrait;
use App\Entities\Podcast; use App\Entities\Podcast;
use App\Libraries\PodcastActor;
use App\Libraries\PodcastEpisode;
use App\Models\EpisodeModel; use App\Models\EpisodeModel;
use App\Models\PodcastModel; use App\Models\PodcastModel;
use App\Models\StatusModel; use App\Models\StatusModel;
use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\Response;
class PodcastController extends BaseController class PodcastController extends BaseController
{ {
...@@ -42,6 +48,15 @@ class PodcastController extends BaseController ...@@ -42,6 +48,15 @@ class PodcastController extends BaseController
return $this->{$method}(...$params); 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 public function activity(): string
{ {
// Prevent analytics hit when authenticated // Prevent analytics hit when authenticated
...@@ -209,4 +224,46 @@ class PodcastController extends BaseController ...@@ -209,4 +224,46 @@ class PodcastController extends BaseController
return $cachedView; 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());
}
} }
...@@ -121,6 +121,11 @@ class Episode extends Entity ...@@ -121,6 +121,11 @@ class Episode extends Entity
*/ */
protected ?array $statuses = null; protected ?array $statuses = null;
/**
* @var Status[]|null
*/
protected ?array $comments = null;
protected ?Location $location = null; protected ?Location $location = null;
protected string $custom_rss_string; protected string $custom_rss_string;
...@@ -387,7 +392,7 @@ class Episode extends Entity ...@@ -387,7 +392,7 @@ class Episode extends Entity
public function getStatuses(): array public function getStatuses(): array
{ {
if ($this->id === null) { 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) { if ($this->statuses === null) {
...@@ -397,6 +402,22 @@ class Episode extends Entity ...@@ -397,6 +402,22 @@ class Episode extends Entity
return $this->statuses; 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 public function getLink(): string
{ {
return base_url(route_to('episode', $this->getPodcast() ->name, $this->attributes['slug'])); return base_url(route_to('episode', $this->getPodcast() ->name, $this->attributes['slug']));
......
...@@ -92,7 +92,7 @@ class StatusController extends Controller ...@@ -92,7 +92,7 @@ class StatusController extends Controller
if ($paginatedReplies !== null) { if ($paginatedReplies !== null) {
foreach ($paginatedReplies as $reply) { foreach ($paginatedReplies as $reply) {
$replyObject = new $noteObjectClass($reply); $replyObject = new $noteObjectClass($reply);
$orderedItems[] = $replyObject->toJSON(); $orderedItems[] = $replyObject->toArray();
} }
} }
......
...@@ -39,7 +39,7 @@ class NoteObject extends ObjectType ...@@ -39,7 +39,7 @@ class NoteObject extends ObjectType
$this->inReplyTo = $status->reply_to_status->uri; $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]; $this->cc = [$status->actor->followers_url];
} }
......
...@@ -28,7 +28,7 @@ class OrderedCollectionObject extends ObjectType ...@@ -28,7 +28,7 @@ class OrderedCollectionObject extends ObjectType
protected ?string $last = null; protected ?string $last = null;
/** /**
* @param ObjectType[] $orderedItems * @param ObjectType[]|null $orderedItems
*/ */
public function __construct( public function __construct(
protected ?array $orderedItems = null, protected ?array $orderedItems = null,
......
...@@ -40,7 +40,7 @@ trait AnalyticsTrait ...@@ -40,7 +40,7 @@ trait AnalyticsTrait
$procedureName = $db->prefixTable('analytics_website'); $procedureName = $db->prefixTable('analytics_website');
$db->query("call {$procedureName}(?,?,?,?,?,?)", [ $db->query("call {$procedureName}(?,?,?,?,?,?)", [
$podcastId, $podcastId,
$session->get('browser'), $session->get('browser') ?? '',
$session->get('entryPage'), $session->get('entryPage'),
$referer, $referer,
$domain, $domain,
......
...@@ -10,21 +10,39 @@ declare(strict_types=1); ...@@ -10,21 +10,39 @@ declare(strict_types=1);
namespace App\Libraries; namespace App\Libraries;
use ActivityPub\Entities\Actor;
use ActivityPub\Objects\ActorObject; use ActivityPub\Objects\ActorObject;
use App\Models\PodcastModel; use App\Entities\Podcast;
class PodcastActor extends ActorObject 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);
$this->context[] = 'https://github.com/Podcastindex-org/activitypub-spec-work/blob/main/docs/1.0.md';
$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;
$podcast = (new PodcastModel())->where('actor_id', $actor->id) $this->category = $category;
->first();
$this->rss = $podcast->feed_url; $this->episodes = url_to('podcast-episodes', $podcast->name);
} }
} }
<?php
declare(strict_types=1);
/**
* @copyright 2021 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Libraries;
use ActivityPub\Core\ObjectType;
use App\Entities\Episode;
class PodcastEpisode extends ObjectType
{
protected string $type = 'PodcastEpisode';
protected string $attributedTo;
protected string $comments;
/**
* @var array<mixed>
*/
protected array $description = [];
/**
* @var array<string, string>
*/
protected array $image = [];
/**
* @var array<mixed>
*/
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];
}
}
}
}
...@@ -12,6 +12,7 @@ namespace App\Models; ...@@ -12,6 +12,7 @@ namespace App\Models;
use ActivityPub\Models\StatusModel as ActivityPubStatusModel; use ActivityPub\Models\StatusModel as ActivityPubStatusModel;
use App\Entities\Status; use App\Entities\Status;
use CodeIgniter\Database\BaseBuilder;
class StatusModel extends ActivityPubStatusModel class StatusModel extends ActivityPubStatusModel
{ {
...@@ -53,4 +54,21 @@ class StatusModel extends ActivityPubStatusModel ...@@ -53,4 +54,21 @@ class StatusModel extends ActivityPubStatusModel
->orderBy('published_at', 'DESC') ->orderBy('published_at', 'DESC')
->findAll(); ->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();
}
} }
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
</a> </a>
</header> </header>
<?php if ($episodes): ?> <?php if ($episodes): ?>
<div class="flex justify-between p-2 space-x-4 overflow-x-auto"> <div class="flex p-2 overflow-x-auto gap-x-6">
<?php foreach ($episodes as $episode): ?> <?php foreach ($episodes as $episode): ?>
<article class="flex flex-col flex-shrink-0 w-56 overflow-hidden bg-white border shadow rounded-xl"> <article class="flex flex-col flex-shrink-0 w-56 overflow-hidden bg-white border shadow rounded-xl">
<img <img
......
Supports Markdown
0%