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

This commit is contained in:
Yassine Doghri 2021-07-12 18:40:22 +00:00
parent b814cfaf7c
commit 9e1e5d2e86
13 changed files with 316 additions and 17 deletions

View File

@ -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;
/**

View File

@ -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',

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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']));

View File

@ -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();
}
}

View File

@ -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];
}

View File

@ -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,

View File

@ -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,

View File

@ -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);
}
}

View File

@ -0,0 +1,89 @@
<?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];
}
}
}
}

View File

@ -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();
}
}

View File

@ -10,7 +10,7 @@
</a>
</header>
<?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): ?>
<article class="flex flex-col flex-shrink-0 w-56 overflow-hidden bg-white border shadow rounded-xl">
<img