mirror of
https://code.castopod.org/adaures/castopod.git
synced 2024-09-05 18:29:20 +02:00
- 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
458 lines
14 KiB
PHP
458 lines
14 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @copyright 2020 Podlibre
|
|
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
|
* @link https://castopod.org/
|
|
*/
|
|
|
|
namespace App\Models;
|
|
|
|
use CodeIgniter\Model;
|
|
|
|
class EpisodeModel extends Model
|
|
{
|
|
protected $table = 'episodes';
|
|
protected $primaryKey = 'id';
|
|
|
|
protected $allowedFields = [
|
|
'id',
|
|
'podcast_id',
|
|
'guid',
|
|
'title',
|
|
'slug',
|
|
'enclosure_uri',
|
|
'enclosure_duration',
|
|
'enclosure_mimetype',
|
|
'enclosure_filesize',
|
|
'enclosure_headersize',
|
|
'description_markdown',
|
|
'description_html',
|
|
'image_uri',
|
|
'image_mimetype',
|
|
'transcript_uri',
|
|
'chapters_uri',
|
|
'parental_advisory',
|
|
'number',
|
|
'season_number',
|
|
'type',
|
|
'is_blocked',
|
|
'location_name',
|
|
'location_geo',
|
|
'location_osmid',
|
|
'custom_rss',
|
|
'favourites_total',
|
|
'reblogs_total',
|
|
'notes_total',
|
|
'published_at',
|
|
'created_by',
|
|
'updated_by',
|
|
];
|
|
|
|
protected $returnType = \App\Entities\Episode::class;
|
|
|
|
protected $useSoftDeletes = true;
|
|
protected $useTimestamps = true;
|
|
|
|
protected $validationRules = [
|
|
'podcast_id' => 'required',
|
|
'title' => 'required',
|
|
'slug' => 'required|regex_match[/^[a-zA-Z0-9\-]{1,191}$/]',
|
|
'enclosure_uri' => 'required',
|
|
'description_markdown' => 'required',
|
|
'number' => 'is_natural_no_zero|permit_empty',
|
|
'season_number' => 'is_natural_no_zero|permit_empty',
|
|
'type' => 'required',
|
|
'published_at' => 'valid_date|permit_empty',
|
|
'created_by' => 'required',
|
|
'updated_by' => 'required',
|
|
];
|
|
protected $validationMessages = [];
|
|
|
|
protected $afterInsert = ['writeEnclosureMetadata', 'clearCache'];
|
|
// clear cache beforeUpdate because if slug changes, so will the episode link
|
|
protected $beforeUpdate = ['clearCache'];
|
|
protected $afterUpdate = ['writeEnclosureMetadata'];
|
|
protected $beforeDelete = ['clearCache'];
|
|
|
|
// TODO: remove
|
|
public static $themes = [
|
|
'light-transparent' => [
|
|
'style' =>
|
|
'background-color: #fff; background-image: linear-gradient(45deg, #ccc 12.5%, transparent 12.5%, transparent 50%, #ccc 50%, #ccc 62.5%, transparent 62.5%, transparent 100%); background-size: 5.66px 5.66px;',
|
|
'background' => 'transparent',
|
|
'text' => '#000',
|
|
'inverted' => '#fff',
|
|
],
|
|
'light' => [
|
|
'style' => 'background-color: #fff;',
|
|
'background' => '#fff',
|
|
'text' => '#000',
|
|
'inverted' => '#fff',
|
|
],
|
|
'dark-transparent' => [
|
|
'style' =>
|
|
'background-color: #001f1a; background-image: linear-gradient(45deg, #888 12.5%, transparent 12.5%, transparent 50%, #888 50%, #888 62.5%, transparent 62.5%, transparent 100%); background-size: 5.66px 5.66px;',
|
|
'background' => 'transparent',
|
|
'text' => '#fff',
|
|
'inverted' => '#000',
|
|
],
|
|
'dark' => [
|
|
'style' => 'background-color: #001f1a;',
|
|
'background' => '#001f1a',
|
|
'text' => '#fff',
|
|
'inverted' => '#000',
|
|
],
|
|
];
|
|
|
|
/**
|
|
*
|
|
* @param int|string $podcastId Podcast Id or name
|
|
* @param mixed $episodeSlug
|
|
* @return mixed
|
|
*/
|
|
public function getEpisodeBySlug($podcastId, $episodeSlug)
|
|
{
|
|
if (!($found = cache("podcast@{$podcastId}_episode@{$episodeSlug}"))) {
|
|
$builder = $this->select('episodes.*')
|
|
->where('slug', $episodeSlug)
|
|
->where('`published_at` <= NOW()', null, false);
|
|
|
|
if (is_numeric($podcastId)) {
|
|
// passed argument is the podcast id
|
|
$builder->where('podcast_id', $podcastId);
|
|
} else {
|
|
// passed argument is the podcast name, must perform join
|
|
$builder
|
|
->join('podcasts', 'podcasts.id = episodes.podcast_id')
|
|
->where('podcasts.name', $podcastId);
|
|
}
|
|
|
|
$found = $builder->first();
|
|
|
|
cache()->save(
|
|
"podcast{$podcastId}_episode@{$episodeSlug}",
|
|
$found,
|
|
DECADE,
|
|
);
|
|
}
|
|
|
|
return $found;
|
|
}
|
|
|
|
public function getEpisodeById($episodeId)
|
|
{
|
|
if (!($found = cache("podcast_episode{$episodeId}"))) {
|
|
$builder = $this->where([
|
|
'id' => $episodeId,
|
|
]);
|
|
|
|
$found = $builder->first();
|
|
|
|
cache()->save("podcast_episode{$episodeId}", $found, DECADE);
|
|
}
|
|
|
|
return $found;
|
|
}
|
|
|
|
public function getPublishedEpisodeById($episodeId, $podcastId = null)
|
|
{
|
|
if (!($found = cache("podcast{$podcastId}_episode{$episodeId}"))) {
|
|
$builder = $this->where([
|
|
'id' => $episodeId,
|
|
])->where('`published_at` <= NOW()', null, false);
|
|
|
|
if ($podcastId) {
|
|
$builder->where('podcast_id', $podcastId);
|
|
}
|
|
|
|
$found = $builder->first();
|
|
|
|
cache()->save(
|
|
"podcast{$podcastId}_episode{$episodeId}",
|
|
$found,
|
|
DECADE,
|
|
);
|
|
}
|
|
|
|
return $found;
|
|
}
|
|
|
|
/**
|
|
* Gets all episodes for a podcast ordered according to podcast type
|
|
* Filtered depending on year or season
|
|
*
|
|
* @param int $podcastId
|
|
*
|
|
* @return \App\Entities\Episode[]
|
|
*/
|
|
public function getPodcastEpisodes(
|
|
int $podcastId,
|
|
string $podcastType,
|
|
string $year = null,
|
|
string $season = null
|
|
): array {
|
|
$cacheName = implode(
|
|
'_',
|
|
array_filter([
|
|
"podcast{$podcastId}",
|
|
$year,
|
|
$season ? 'season' . $season : null,
|
|
'episodes',
|
|
]),
|
|
);
|
|
|
|
if (!($found = cache($cacheName))) {
|
|
$where = [
|
|
'podcast_id' => $podcastId,
|
|
];
|
|
if ($year) {
|
|
$where['YEAR(published_at)'] = $year;
|
|
$where['season_number'] = null;
|
|
}
|
|
if ($season) {
|
|
$where['season_number'] = $season;
|
|
}
|
|
|
|
if ($podcastType == 'serial') {
|
|
// podcast is serial
|
|
$found = $this->where($where)
|
|
->where('`published_at` <= NOW()', null, false)
|
|
->orderBy('season_number DESC, number ASC')
|
|
->findAll();
|
|
} else {
|
|
$found = $this->where($where)
|
|
->where('`published_at` <= NOW()', null, false)
|
|
->orderBy('published_at', 'DESC')
|
|
->findAll();
|
|
}
|
|
|
|
$secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
|
|
$podcastId,
|
|
);
|
|
|
|
cache()->save(
|
|
$cacheName,
|
|
$found,
|
|
$secondsToNextUnpublishedEpisode
|
|
? $secondsToNextUnpublishedEpisode
|
|
: DECADE,
|
|
);
|
|
}
|
|
|
|
return $found;
|
|
}
|
|
|
|
public function getYears(int $podcastId): array
|
|
{
|
|
if (!($found = cache("podcast{$podcastId}_years"))) {
|
|
$found = $this->select(
|
|
'YEAR(published_at) as year, count(*) as number_of_episodes',
|
|
)
|
|
->where([
|
|
'podcast_id' => $podcastId,
|
|
'season_number' => null,
|
|
$this->deletedField => null,
|
|
])
|
|
->where('`published_at` <= NOW()', null, false)
|
|
->groupBy('year')
|
|
->orderBy('year', 'DESC')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
$secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
|
|
$podcastId,
|
|
);
|
|
|
|
cache()->save(
|
|
"podcast{$podcastId}_years",
|
|
$found,
|
|
$secondsToNextUnpublishedEpisode
|
|
? $secondsToNextUnpublishedEpisode
|
|
: DECADE,
|
|
);
|
|
}
|
|
|
|
return $found;
|
|
}
|
|
|
|
public function getSeasons(int $podcastId): array
|
|
{
|
|
if (!($found = cache("podcast{$podcastId}_seasons"))) {
|
|
$found = $this->select(
|
|
'season_number, count(*) as number_of_episodes',
|
|
)
|
|
->where([
|
|
'podcast_id' => $podcastId,
|
|
'season_number is not' => null,
|
|
$this->deletedField => null,
|
|
])
|
|
->where('`published_at` <= NOW()', null, false)
|
|
->groupBy('season_number')
|
|
->orderBy('season_number', 'ASC')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
$secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
|
|
$podcastId,
|
|
);
|
|
|
|
cache()->save(
|
|
"podcast{$podcastId}_seasons",
|
|
$found,
|
|
$secondsToNextUnpublishedEpisode
|
|
? $secondsToNextUnpublishedEpisode
|
|
: DECADE,
|
|
);
|
|
}
|
|
|
|
return $found;
|
|
}
|
|
|
|
/**
|
|
* Returns the default query for displaying the episode list on the podcast page
|
|
*
|
|
* @param int $podcastId
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getDefaultQuery(int $podcastId)
|
|
{
|
|
if (!($defaultQuery = cache("podcast{$podcastId}_defaultQuery"))) {
|
|
$seasons = $this->getSeasons($podcastId);
|
|
|
|
if (!empty($seasons)) {
|
|
// get latest season
|
|
$defaultQuery = ['type' => 'season', 'data' => end($seasons)];
|
|
} else {
|
|
$years = $this->getYears($podcastId);
|
|
if (!empty($years)) {
|
|
// get most recent year
|
|
$defaultQuery = ['type' => 'year', 'data' => $years[0]];
|
|
} else {
|
|
$defaultQuery = null;
|
|
}
|
|
}
|
|
|
|
cache()->save(
|
|
"podcast{$podcastId}_defaultQuery",
|
|
$defaultQuery,
|
|
DECADE,
|
|
);
|
|
}
|
|
return $defaultQuery;
|
|
}
|
|
|
|
/**
|
|
* Returns the timestamp difference in seconds between the next episode to publish and the current timestamp
|
|
* Returns false if there's no episode to publish
|
|
*
|
|
* @param int $podcastId
|
|
*
|
|
* @return int|false seconds
|
|
*/
|
|
public function getSecondsToNextUnpublishedEpisode(int $podcastId)
|
|
{
|
|
$result = $this->select(
|
|
'TIMESTAMPDIFF(SECOND, NOW(), `published_at`) as timestamp_diff',
|
|
)
|
|
->where([
|
|
'podcast_id' => $podcastId,
|
|
])
|
|
->where('`published_at` > NOW()', null, false)
|
|
->orderBy('published_at', 'asc')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
return (int) $result ? $result[0]['timestamp_diff'] : false;
|
|
}
|
|
|
|
protected function writeEnclosureMetadata(array $data)
|
|
{
|
|
helper('id3');
|
|
|
|
$episode = (new EpisodeModel())->find(
|
|
is_array($data['id']) ? $data['id'][0] : $data['id'],
|
|
);
|
|
|
|
write_enclosure_tags($episode);
|
|
|
|
return $data;
|
|
}
|
|
|
|
public function clearCache(array $data)
|
|
{
|
|
$episode = (new EpisodeModel())->find(
|
|
is_array($data['id']) ? $data['id'][0] : $data['id'],
|
|
);
|
|
|
|
// delete cache for rss feed
|
|
cache()->delete("podcast{$episode->podcast_id}_feed");
|
|
foreach (\Opawg\UserAgentsPhp\UserAgentsRSS::$db as $service) {
|
|
cache()->delete(
|
|
"podcast{$episode->podcast_id}_feed_{$service['slug']}",
|
|
);
|
|
}
|
|
|
|
// delete model requests cache
|
|
cache()->delete("podcast{$episode->podcast_id}_episodes");
|
|
|
|
cache()->delete(
|
|
"podcast{$episode->podcast_id}_episode@{$episode->slug}",
|
|
);
|
|
|
|
cache()->delete("podcast_episode{$episode->id}");
|
|
|
|
// delete episode lists cache per year / season for a podcast
|
|
// and localized pages
|
|
$episodeModel = new EpisodeModel();
|
|
$years = $episodeModel->getYears($episode->podcast_id);
|
|
$seasons = $episodeModel->getSeasons($episode->podcast_id);
|
|
$supportedLocales = config('App')->supportedLocales;
|
|
|
|
foreach ($supportedLocales as $locale) {
|
|
cache()->delete(
|
|
"page_podcast{$episode->podcast->id}_episode{$episode->id}_{$locale}",
|
|
);
|
|
cache()->delete("credits_{$locale}");
|
|
}
|
|
|
|
foreach ($years as $year) {
|
|
cache()->delete(
|
|
"podcast{$episode->podcast_id}_{$year['year']}_episodes",
|
|
);
|
|
foreach ($supportedLocales as $locale) {
|
|
cache()->delete(
|
|
"page_podcast{$episode->podcast_id}_{$year['year']}_{$locale}",
|
|
);
|
|
}
|
|
}
|
|
|
|
foreach ($seasons as $season) {
|
|
cache()->delete(
|
|
"podcast{$episode->podcast_id}_season{$season['season_number']}_episodes",
|
|
);
|
|
foreach ($supportedLocales as $locale) {
|
|
cache()->delete(
|
|
"page_podcast{$episode->podcast_id}_season{$season['season_number']}_{$locale}",
|
|
);
|
|
}
|
|
}
|
|
|
|
foreach (array_keys(self::$themes) as $themeKey) {
|
|
foreach ($supportedLocales as $locale) {
|
|
cache()->delete(
|
|
"page_podcast{$episode->podcast_id}_episode{$episode->id}_embeddable_player_{$themeKey}_{$locale}",
|
|
);
|
|
}
|
|
}
|
|
|
|
// delete query cache
|
|
cache()->delete("podcast{$episode->podcast_id}_defaultQuery");
|
|
cache()->delete("podcast{$episode->podcast_id}_years");
|
|
cache()->delete("podcast{$episode->podcast_id}_seasons");
|
|
|
|
return $data;
|
|
}
|
|
}
|