castopod/app/Entities/Episode.php
Yassine Doghri 2f525c0f6e feat(fediverse): implement activitypub protocols + update user interface
- add "ActivityPub" library to handle server to server federation and basic
  client to server protocols using activitypub:
  - add webfinger endpoint to look for actor
  - add actor definition with inbox / outbox / followers
  - remote follow an actor
  - create notes with possible preview cards
  - interract with favourites, reblogs and replies
  - block incoming actors and/or domains
  - broadcast/schedule activities to fediverse followers using a cron task
- For castopod, the podcast is the actor:
  - overwrite the activitypub library for castopod's specific needs
  - perform basic interactions administrating a podcast to interact with fediverse users:
    - create notes with episode attachment
    - favourite and share a note + reply
    - add specific castopod_namespaces for podcasts and episodes definitions
- overwrite CodeIgniter's Route service to include alternate-content option for
  activitystream requests
- update episode publication logic:
  - remove publication inputs in create / edit episode form
  - publish / schedule or unpublish an episode after creation
  - the podcaster publishes a note when publishing an episode
- Javascript / Typescript modules:
  - fix Dropdown.ts to keep dropdown menu in foreground
  - add Modal.ts for funding links modal
  - add Toggler.ts to toggle various css states in ui
- User Interface:
  - update tailwindcss to v2
  - use castopod's pine and rose colors
  - update public layout to a 3 column layout
  - add pages in public for podcast activity, episode list and notes
  - update episode page to include linked notes
  - remove previous and next episodes from episode pages
  - show different public views depending on whether user is authenticated or not
  - use Kumbh Sans and Montserrat fonts
- update CodeIgniter's config files
- with CodeIgniter's new requirements, update docker environments are now based on
  php v7.3 image
- move Image entity to Libraries
- update composer and npm packages to latest versions

closes #69 #65 #85, fixes #51 #91 #92 #88
2021-04-02 17:20:02 +00:00

666 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use App\Models\PodcastModel;
use App\Models\SoundbiteModel;
use App\Models\EpisodePersonModel;
use App\Models\NoteModel;
use CodeIgniter\Entity;
use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter;
class Episode extends Entity
{
/**
* @var \App\Entities\Podcast
*/
protected $podcast;
/**
* @var string
*/
protected $link;
/**
* @var \App\Libraries\Image
*/
protected $image;
/**
* @var \CodeIgniter\Files\File
*/
protected $enclosure;
/**
* @var \CodeIgniter\Files\File
*/
protected $transcript;
/**
* @var \CodeIgniter\Files\File
*/
protected $chapters;
/**
* @var string
*/
protected $enclosure_media_path;
/**
* @var string
*/
protected $enclosure_url;
/**
* @var string
*/
protected $enclosure_web_url;
/**
* @var string
*/
protected $enclosure_opengraph_url;
/**
* @var string
*/
protected $transcript_url;
/**
* @var string
*/
protected $chapters_url;
/**
* @var \App\Entities\EpisodePerson[]
*/
protected $persons;
/**
* @var \App\Entities\Soundbite[]
*/
protected $soundbites;
/**
* @var \App\Entities\Note[]
*/
protected $notes;
/**
* Holds text only description, striped of any markdown or html special characters
*
* @var string
*/
protected $description;
/**
* The embeddable player URL
*
* @var string
*/
protected $embeddable_player;
/**
* @var string
*/
protected $publication_status;
/**
* Return custom rss as string
*
* @var string
*/
protected $custom_rss_string;
protected $dates = [
'published_at',
'created_at',
'updated_at',
'deleted_at',
];
protected $casts = [
'id' => 'integer',
'podcast_id' => 'integer',
'guid' => 'string',
'slug' => 'string',
'title' => 'string',
'enclosure_uri' => 'string',
'enclosure_duration' => 'integer',
'enclosure_mimetype' => 'string',
'enclosure_filesize' => 'integer',
'enclosure_headersize' => 'integer',
'description_markdown' => 'string',
'description_html' => 'string',
'image_uri' => '?string',
'image_mimetype' => '?string',
'transcript_uri' => '?string',
'chapters_uri' => '?string',
'parental_advisory' => '?string',
'number' => '?integer',
'season_number' => '?integer',
'type' => 'string',
'is_blocked' => 'boolean',
'location_name' => '?string',
'location_geo' => '?string',
'location_osmid' => '?string',
'custom_rss' => '?json-array',
'favourites_total' => 'integer',
'reblogs_total' => 'integer',
'notes_total' => 'integer',
'created_by' => 'integer',
'updated_by' => 'integer',
];
/**
* Saves an episode image
*
* @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $image
*
*/
public function setImage($image)
{
if (
!empty($image) &&
(!($image instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
$image->isValid())
) {
helper('media');
// check whether the user has inputted an image and store
$this->attributes['image_mimetype'] = $image->getMimeType();
$this->attributes['image_uri'] = save_media(
$image,
'podcasts/' . $this->getPodcast()->name,
$this->attributes['slug'],
);
$this->image = new \App\Libraries\Image(
$this->attributes['image_uri'],
$this->attributes['image_mimetype'],
);
$this->image->saveSizes();
}
return $this;
}
public function getImage(): \App\Libraries\Image
{
if ($image_uri = $this->attributes['image_uri']) {
return new \App\Libraries\Image(
$image_uri,
$this->attributes['image_mimetype'],
);
}
return $this->getPodcast()->image;
}
/**
* Saves an enclosure
*
* @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $enclosure
*
*/
public function setEnclosure($enclosure = null)
{
if (
!empty($enclosure) &&
(!($enclosure instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
$enclosure->isValid())
) {
helper(['media', 'id3']);
$enclosure_metadata = get_file_tags($enclosure);
$this->attributes['enclosure_uri'] = save_media(
$enclosure,
'podcasts/' . $this->getPodcast()->name,
$this->attributes['slug'],
);
$this->attributes['enclosure_duration'] = round(
$enclosure_metadata['playtime_seconds'],
);
$this->attributes['enclosure_mimetype'] =
$enclosure_metadata['mime_type'];
$this->attributes['enclosure_filesize'] =
$enclosure_metadata['filesize'];
$this->attributes['enclosure_headersize'] =
$enclosure_metadata['avdataoffset'];
return $this;
}
}
/**
* Saves an episode transcript
*
* @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $transcript
*
*/
public function setTranscript($transcript)
{
if (
!empty($transcript) &&
(!($transcript instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
$transcript->isValid())
) {
helper('media');
$this->attributes['transcript_uri'] = save_media(
$transcript,
$this->getPodcast()->name,
$this->attributes['slug'] . '-transcript',
);
}
return $this;
}
/**
* Saves an episode chapters
*
* @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $chapters
*
*/
public function setChapters($chapters)
{
if (
!empty($chapters) &&
(!($chapters instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
$chapters->isValid())
) {
helper('media');
$this->attributes['chapters_uri'] = save_media(
$chapters,
$this->getPodcast()->name,
$this->attributes['slug'] . '-chapters',
);
}
return $this;
}
public function getEnclosure()
{
return new \CodeIgniter\Files\File($this->getEnclosureMediaPath());
}
public function getTranscript()
{
return $this->attributes['transcript_uri']
? new \CodeIgniter\Files\File($this->getTranscriptMediaPath())
: null;
}
public function getChapters()
{
return $this->attributes['chapters_uri']
? new \CodeIgniter\Files\File($this->getChaptersMediaPath())
: null;
}
public function getEnclosureMediaPath()
{
helper('media');
return media_path($this->attributes['enclosure_uri']);
}
public function getTranscriptMediaPath()
{
helper('media');
return $this->attributes['transcript_uri']
? media_path($this->attributes['transcript_uri'])
: null;
}
public function getChaptersMediaPath()
{
helper('media');
return $this->attributes['chapters_uri']
? media_path($this->attributes['chapters_uri'])
: null;
}
public function getEnclosureUrl()
{
helper('analytics');
return base_url(
route_to(
'analytics_hit',
base64_url_encode(
pack(
'I*',
$this->attributes['podcast_id'],
$this->attributes['id'],
// bytes_threshold: number of bytes that must be downloaded for an episode to be counted in download analytics
// - if file is shorter than 60sec, then it's enclosure_filesize
// - if file is longer than 60 seconds then it's enclosure_headersize + 60 seconds
$this->attributes['enclosure_duration'] <= 60
? $this->attributes['enclosure_filesize']
: $this->attributes['enclosure_headersize'] +
floor(
(($this->attributes['enclosure_filesize'] -
$this->attributes[
'enclosure_headersize'
]) /
$this->attributes[
'enclosure_duration'
]) *
60,
),
$this->attributes['enclosure_filesize'],
$this->attributes['enclosure_duration'],
strtotime($this->attributes['published_at']),
),
),
$this->attributes['enclosure_uri'],
),
);
}
public function getEnclosureWebUrl()
{
return $this->getEnclosureUrl() . '?_from=-+Website+-';
}
public function getEnclosureOpengraphUrl()
{
return $this->getEnclosureUrl() . '?_from=-+Open+Graph+-';
}
public function getTranscriptUrl()
{
return $this->attributes['transcript_uri']
? base_url($this->getTranscriptMediaPath())
: null;
}
public function getChaptersUrl()
{
return $this->attributes['chapters_uri']
? base_url($this->getChaptersMediaPath())
: null;
}
/**
* Returns the episode's persons
*
* @return \App\Entities\EpisodePerson[]
*/
public function getPersons()
{
if (empty($this->id)) {
throw new \RuntimeException(
'Episode must be created before getting persons.',
);
}
if (empty($this->persons)) {
$this->persons = (new EpisodePersonModel())->getPersonsByEpisodeId(
$this->podcast_id,
$this->id,
);
}
return $this->persons;
}
/**
* Returns the episodes soundbites
*
* @return \App\Entities\Episode[]
*/
public function getSoundbites()
{
if (empty($this->id)) {
throw new \RuntimeException(
'Episode must be created before getting soundbites.',
);
}
if (empty($this->soundbites)) {
$this->soundbites = (new SoundbiteModel())->getEpisodeSoundbites(
$this->getPodcast()->id,
$this->id,
);
}
return $this->soundbites;
}
public function getNotes()
{
if (empty($this->id)) {
throw new \RuntimeException(
'Episode must be created before getting soundbites.',
);
}
if (empty($this->notes)) {
$this->notes = (new NoteModel())->getEpisodeNotes($this->id);
}
return $this->notes;
}
public function getLink()
{
return base_url(
route_to(
'episode',
$this->getPodcast()->name,
$this->attributes['slug'],
),
);
}
public function getEmbeddablePlayer($theme = null)
{
return base_url(
$theme
? route_to(
'embeddable-player-theme',
$this->getPodcast()->name,
$this->attributes['slug'],
$theme,
)
: route_to(
'embeddable-player',
$this->getPodcast()->name,
$this->attributes['slug'],
),
);
}
public function setGuid(string $guid)
{
return $this->attributes['guid'] = empty($guid)
? $this->getLink()
: $guid;
}
public function getPodcast()
{
return (new PodcastModel())->getPodcastById(
$this->attributes['podcast_id'],
);
}
public function setDescriptionMarkdown(string $descriptionMarkdown)
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
$this->attributes['description_markdown'] = $descriptionMarkdown;
$this->attributes['description_html'] = $converter->convertToHtml(
$descriptionMarkdown,
);
return $this;
}
public function getDescriptionHtml($serviceSlug = null)
{
return (empty($this->getPodcast()->partner_id) ||
empty($this->getPodcast()->partner_link_url) ||
empty($this->getPodcast()->partner_image_url)
? ''
: "<div><a href=\"{$this->getPartnerLink(
$serviceSlug
)}\" rel=\"sponsored noopener noreferrer\" target=\"_blank\"><img src=\"{$this->getPartnerImage(
$serviceSlug
)}\" alt=\"Partner image\" /></a></div>") .
$this->attributes['description_html'] .
(empty($this->getPodcast()->episode_description_footer_html)
? ''
: "<footer>{$this->getPodcast()->episode_description_footer_html}</footer>");
}
public function getDescription()
{
if ($this->description) {
return $this->description;
}
return trim(
preg_replace(
'/\s+/',
' ',
strip_tags($this->attributes['description_html']),
),
);
}
public function getPublicationStatus()
{
if ($this->publication_status) {
return $this->publication_status;
}
if (!$this->published_at) {
return 'not_published';
}
helper('date');
if ($this->published_at->isBefore(Time::now())) {
return 'published';
}
return 'scheduled';
}
/**
* Saves the location name and fetches OpenStreetMap info
*
* @param string $locationName
*
*/
public function setLocation($locationName = null)
{
helper('location');
if (
$locationName &&
(empty($this->attributes['location_name']) ||
$this->attributes['location_name'] != $locationName)
) {
$this->attributes['location_name'] = $locationName;
if ($location = fetch_osm_location($locationName)) {
$this->attributes['location_geo'] = $location['geo'];
$this->attributes['location_osmid'] = $location['osmid'];
}
} elseif (empty($locationName)) {
$this->attributes['location_name'] = null;
$this->attributes['location_geo'] = null;
$this->attributes['location_osmid'] = null;
}
return $this;
}
/**
* Get custom rss tag as XML String
*
* @return string
*
*/
function getCustomRssString()
{
helper('rss');
if (empty($this->attributes['custom_rss'])) {
return '';
} else {
$xmlNode = (new \App\Libraries\SimpleRSSElement(
'<?xml version="1.0" encoding="utf-8"?><rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"/>',
))
->addChild('channel')
->addChild('item');
array_to_rss(
[
'elements' => $this->custom_rss,
],
$xmlNode,
);
return str_replace(['<item>', '</item>'], '', $xmlNode->asXML());
}
}
/**
* Saves custom rss tag into json
*
* @param string $customRssString
*
*/
function setCustomRssString($customRssString)
{
helper('rss');
$customRssArray = rss_to_array(
simplexml_load_string(
'<?xml version="1.0" encoding="utf-8"?><rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"><channel><item>' .
$customRssString .
'</item></channel></rss>',
),
)['elements'][0]['elements'][0];
if (array_key_exists('elements', $customRssArray)) {
$this->attributes['custom_rss'] = json_encode(
$customRssArray['elements'],
);
} else {
$this->attributes['custom_rss'] = null;
}
}
function getPartnerLink($serviceSlug = null)
{
return rtrim($this->getPodcast()->partner_link_url, '/') .
'?pid=' .
$this->getPodcast()->partner_id .
'&guid=' .
urlencode($this->attributes['guid']) .
(empty($serviceSlug) ? '' : '&_from=' . $serviceSlug);
}
function getPartnerImage($serviceSlug = null)
{
return rtrim($this->getPodcast()->partner_image_url, '/') .
'?pid=' .
$this->getPodcast()->partner_id .
'&guid=' .
urlencode($this->attributes['guid']) .
(empty($serviceSlug) ? '' : '&_from=' . $serviceSlug);
}
}