Newer
Older
<?php

Yassine Doghri
committed

Yassine Doghri
committed
declare(strict_types=1);
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Models;

Yassine Doghri
committed
use App\Entities\Episode;
Krzysztof Domańczy
committed
use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\BaseResult;

Yassine Doghri
committed
use Michalsn\Uuid\UuidModel;
use Ramsey\Uuid\Lazy\LazyUuidFromString;

Yassine Doghri
committed
class EpisodeModel extends UuidModel

Yassine Doghri
committed
/**

Yassine Doghri
committed
* TODO: remove, shouldn't be here
*

Yassine Doghri
committed
* @var array<string, array<string, string>>
*/
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;',

Yassine Doghri
committed
'background' => 'transparent',
'text' => '#000',
'inverted' => '#fff',

Yassine Doghri
committed
],
'light' => [
'style' => 'background-color: #fff;',

Yassine Doghri
committed
'background' => '#fff',
'text' => '#000',
'inverted' => '#fff',

Yassine Doghri
committed
],
'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;',

Yassine Doghri
committed
'background' => 'transparent',
'text' => '#fff',
'inverted' => '#000',

Yassine Doghri
committed
],
'dark' => [
'style' => 'background-color: #001f1a;',

Yassine Doghri
committed
'background' => '#313131',
'text' => '#fff',
'inverted' => '#000',

Yassine Doghri
committed
],
];

Yassine Doghri
committed
/**
* @var string[]
*/
protected $uuidFields = ['preview_id'];

Yassine Doghri
committed
/**
* @var string
*/
protected $table = 'episodes';

Yassine Doghri
committed
/**
* @var string[]
*/
protected $allowedFields = [
'id',
'podcast_id',

Yassine Doghri
committed
'preview_id',
'title',
'audio_id',
'description_markdown',
'description_html',
'cover_id',
'transcript_id',
'transcript_remote_url',
'chapters_id',
'chapters_remote_url',
'parental_advisory',
'season_number',
'type',
'is_blocked',
'location_name',
'location_geo',

Yassine Doghri
committed
'location_osm',

Benjamin Bellamy
committed
'custom_rss',
'is_published_on_hubs',
'posts_count',
'comments_count',
'is_premium',
'published_at',
'created_by',
'updated_by',
];

Yassine Doghri
committed
/**
* @var string
*/
protected $returnType = Episode::class;

Yassine Doghri
committed
/**
* @var bool
*/
protected $useTimestamps = true;

Yassine Doghri
committed
/**
* @var array<string, string>
*/
protected $validationRules = [
'podcast_id' => 'required',
'title' => 'required',
'slug' => 'required|regex_match[/^[a-zA-Z0-9\-]{1,128}$/]',
'audio_id' => 'required',
'description_markdown' => 'required',
'number' => 'is_natural_no_zero|permit_empty',
'season_number' => 'is_natural_no_zero|permit_empty',
'type' => 'required',

Yassine Doghri
committed
'transcript_remote_url' => 'valid_url_strict|permit_empty',
'chapters_remote_url' => 'valid_url_strict|permit_empty',
'published_at' => 'valid_date|permit_empty',
'created_by' => 'required',
'updated_by' => 'required',
];

Yassine Doghri
committed
/**
* @var string[]
*/

Yassine Doghri
committed
protected $afterInsert = ['writeEnclosureMetadata', 'clearCache'];

Yassine Doghri
committed
/**
* @var string[]
*/

Yassine Doghri
committed
protected $afterUpdate = ['clearCache', 'writeEnclosureMetadata'];
/**

Yassine Doghri
committed
* @var string[]
*/

Yassine Doghri
committed
protected $beforeDelete = ['clearCache'];
public function getEpisodeBySlug(string $podcastHandle, string $episodeSlug): ?Episode
$cacheName = "podcast-{$podcastHandle}_episode-{$episodeSlug}";
if (! ($found = cache($cacheName))) {

Yassine Doghri
committed
$found = $this->select('episodes.*')
->join('podcasts', 'podcasts.id = episodes.podcast_id')
->where('slug', $episodeSlug)
->where('podcasts.handle', $podcastHandle)
->where('`' . $this->db->getPrefix() . 'episodes`.`published_at` <= UTC_TIMESTAMP()', null, false)

Yassine Doghri
committed
->first();
cache()
->save($cacheName, $found, DECADE);
}
return $found;
}
public function getEpisodeById(int $episodeId): ?Episode
// TODO: episode id should be a composite key. The cache should include podcast_id.
$cacheName = "podcast_episode#{$episodeId}";
if (! ($found = cache($cacheName))) {
$builder = $this->where([
'id' => $episodeId,
]);
$found = $builder->first();
cache()
->save($cacheName, $found, DECADE);
}
return $found;
}
public function getPublishedEpisodeById(int $podcastId, int $episodeId): ?Episode

Yassine Doghri
committed
{
$cacheName = "podcast#{$podcastId}_episode#{$episodeId}_published";
if (! ($found = cache($cacheName))) {
$found = $this->where([
'id' => $episodeId,
])
->where('podcast_id', $podcastId)

Yassine Doghri
committed
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
->first();

Yassine Doghri
committed
cache()
->save($cacheName, $found, DECADE);
}
return $found;

Yassine Doghri
committed
}

Yassine Doghri
committed
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
public function getEpisodeByPreviewId(string $previewId): ?Episode
{
$cacheName = "podcast_episode#preview-{$previewId}";
if (! ($found = cache($cacheName))) {
$builder = $this->where([
'preview_id' => $this->uuid->fromString($previewId)
->getBytes(),
]);
$found = $builder->first();
cache()
->save($cacheName, $found, DECADE);
}
return $found;
}
public function setEpisodePreviewId(int $episodeId): string|false
{
/** @var LazyUuidFromString $uuid */
$uuid = $this->uuid->{$this->uuidVersion}();
if (! $this->update($episodeId, [
'preview_id' => $uuid,
])) {
return false;
}
return (string) $uuid;
}

Yassine Doghri
committed
/**
* Gets all episodes for a podcast ordered according to podcast type Filtered depending on year or season

Yassine Doghri
committed
*

Yassine Doghri
committed
* @return Episode[]

Yassine Doghri
committed
*/
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))) {

Yassine Doghri
committed
$where = [
'podcast_id' => $podcastId,
];
if ($year) {
$where['YEAR(published_at)'] = $year;
$where['season_number'] = null;
}

Yassine Doghri
committed
if ($season) {
$where['season_number'] = $season;
}
if ($podcastType === 'serial') {
// podcast is serial
$found = $this->where($where)

Yassine Doghri
committed
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
->orderBy('season_number DESC, number ASC')
->findAll();
} else {
$found = $this->where($where)

Yassine Doghri
committed
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
->orderBy('published_at', 'DESC')
->findAll();
}

Yassine Doghri
committed
$secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode($podcastId);

Yassine Doghri
committed
cache()
->save(
$cacheName,
$found,
$secondsToNextUnpublishedEpisode

Yassine Doghri
committed
? $secondsToNextUnpublishedEpisode
: DECADE,
);
}
return $found;
}
/**
* Returns number of episodes of a podcast
*/
public function getPodcastEpisodesCount(int $podcastId): int|string
{
return $this
->where([
'podcast_id' => $podcastId,
])
->countAllResults();
}

Yassine Doghri
committed
/**
* 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

Yassine Doghri
committed
*

Yassine Doghri
committed
* @return int|false seconds

Yassine Doghri
committed
*/

Yassine Doghri
committed
public function getSecondsToNextUnpublishedEpisode(int $podcastId): int | false

Yassine Doghri
committed
{
$result = $this->builder()
->select('TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(), `published_at`) as timestamp_diff')

Yassine Doghri
committed
->where([
'podcast_id' => $podcastId,
])

Yassine Doghri
committed
->where('`published_at` > UTC_TIMESTAMP()', null, false)

Yassine Doghri
committed
->orderBy('published_at', 'asc')
->get()
->getResultArray();

Yassine Doghri
committed
return $result !== []
? (int) $result[0]['timestamp_diff']
: false;

Yassine Doghri
committed
}

Yassine Doghri
committed
public function getCurrentSeasonNumber(int $podcastId): ?int
{
$result = $this->builder()
->selectMax('season_number', 'current_season_number')

Yassine Doghri
committed
->where([
'podcast_id' => $podcastId,
'published_at IS NOT' => null,

Yassine Doghri
committed
])
->get()
->getResultArray();
return $result[0]['current_season_number'] ? (int) $result[0]['current_season_number'] : null;
}
public function getNextEpisodeNumber(int $podcastId, ?int $seasonNumber): int
{
$result = $this->builder()
->selectMax('number', 'next_episode_number')

Yassine Doghri
committed
->where([
'podcast_id' => $podcastId,
'season_number' => $seasonNumber,
'published_at IS NOT' => null,

Yassine Doghri
committed
])->get()
->getResultArray();
return (int) $result[0]['next_episode_number'] + 1;
}
* @return array{number_of_seasons: int, number_of_episodes: int, first_published_at?: Time}
*/
public function getPodcastStats(int $podcastId): array
{
$result = $this->builder()
->select(
'COUNT(DISTINCT season_number) as number_of_seasons, COUNT(*) as number_of_episodes, MIN(published_at) as first_published_at'
)
'podcast_id' => $podcastId,
'published_at IS NOT' => null,
])->get()
->getResultArray();
$stats = [
'number_of_seasons' => (int) $result[0]['number_of_seasons'],
'number_of_episodes' => (int) $result[0]['number_of_episodes'],
];
if ($result[0]['first_published_at'] !== null) {
$stats['first_published_at'] = new Time($result[0]['first_published_at']);
}
return $stats;
}
public function resetCommentsCount(): int | false
{
$episodeCommentsCount = (new EpisodeCommentModel())->builder()
->select('episode_id, COUNT(*) as `comments_count`')
->where('in_reply_to_id', null)
->groupBy('episode_id')
->getCompiledSelect();
$episodePostsRepliesCount = (new PostModel())->builder()

Yassine Doghri
committed
->select('fediverse_posts.episode_id as episode_id, COUNT(*) as `comments_count`')
->join('fediverse_posts as fp', 'fediverse_posts.id = fp.in_reply_to_id')
->where('fediverse_posts.in_reply_to_id', null)
->groupBy('fediverse_posts.episode_id')
->getCompiledSelect();
/** @var BaseResult $query */
$query = $this->db->query(
'SELECT `episode_id` as `id`, SUM(`comments_count`) as `comments_count` FROM (' . $episodeCommentsCount . ' UNION ALL ' . $episodePostsRepliesCount . ') x GROUP BY `episode_id`'
);
$countsPerEpisodeId = $query->getResultArray();
if ($countsPerEpisodeId !== []) {
return (new self())->updateBatch($countsPerEpisodeId, 'id');
}
return 0;
}
public function resetPostsCount(): int | false
{
$episodePostsCount = $this->builder()
->select('episodes.id, COUNT(*) as `posts_count`')

Yassine Doghri
committed
->join('fediverse_posts', 'episodes.id = fediverse_posts.episode_id')
->where('in_reply_to_id', null)
->groupBy('episodes.id')
->get()
->getResultArray();
if ($episodePostsCount !== []) {
return $this->updateBatch($episodePostsCount, 'id');
}
return 0;
}

Yassine Doghri
committed
/**
* @param mixed[] $data
*
* @return mixed[]

Yassine Doghri
committed
*/
public function clearCache(array $data): array
{
/** @var int|null $episodeId */
$episodeId = is_array($data['id']) ? $data['id'][0] : $data['id'];
if ($episodeId === null) {
// Multiple episodes have been updated, do nothing
return $data;
}
/** @var ?Episode $episode */
$episode = (new self())->find($episodeId);
if (! $episode instanceof Episode) {
return $data;
}

Yassine Doghri
committed
// delete podcast cache
cache()

Yassine Doghri
committed
->deleteMatching("podcast#{$episode->podcast_id}*");
cache()
->deleteMatching("podcast-{$episode->podcast->handle}*");
cache()
->delete("podcast_episode#{$episode->id}");
cache()

Yassine Doghri
committed
->deleteMatching("page_podcast#{$episode->podcast_id}*");
cache()
->deleteMatching('page_credits_*');
cache()
->delete('episodes_markers');

Yassine Doghri
committed
return $data;
}

Yassine Doghri
committed
public function doesPodcastHavePremiumEpisodes(int $podcastId): bool
{
return $this->builder()
->where([
'podcast_id' => $podcastId,
'is_premium' => true,

Yassine Doghri
committed
])
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
->countAllResults() > 0;
}
Krzysztof Domańczy
committed
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
public function fullTextSearch(string $query): ?BaseBuilder
{
$prefix = $this->db->getPrefix();
$episodeTable = $prefix . $this->builder()->getTable();
$podcastModel = (new PodcastModel());
$podcastTable = $podcastModel->db->getPrefix() . $podcastModel->builder()->getTable();
$this->builder()
->select('' . $episodeTable . '.*')
->select('
' . $this->getFullTextMatchClauseForEpisodes($episodeTable, $query) . ' as episodes_score,
' . $podcastModel->getFullTextMatchClauseForPodcasts($podcastTable, $query) . ' as podcasts_score,
')
->select("{$podcastTable}.created_at AS podcast_created_at")
->select(
"{$podcastTable}.title as podcast_title, {$podcastTable}.handle as podcast_handle, {$podcastTable}.description_markdown as podcast_description_markdown"
)
->join($podcastTable, "{$podcastTable} on {$podcastTable}.id = {$episodeTable}.podcast_id")
->where('
(' .
$this->getFullTextMatchClauseForEpisodes($episodeTable, $query)
. 'OR' .
$podcastModel->getFullTextMatchClauseForPodcasts($podcastTable, $query)
. ')
');
return $this->builder;
}
public function getFullTextMatchClauseForEpisodes(string $table, string $value): string
{
return '
MATCH (
' . $table . '.title,
' . $table . '.description_markdown,
' . $table . '.slug,
' . $table . '.location_name
)
AGAINST(' . $this->db->escape($value) . ')
';
}

Yassine Doghri
committed
/**
* @param mixed[] $data
*
* @return mixed[]

Yassine Doghri
committed
*/
protected function writeEnclosureMetadata(array $data): array
{
/** @var int|null $episodeId */
$episodeId = is_array($data['id']) ? $data['id'][0] : $data['id'];

Yassine Doghri
committed
if ($episodeId === null) {
// Multiple episodes have been updated, do nothing
return $data;
}
/** @var ?Episode $episode */
$episode = (new self())->find($episodeId);
if (! $episode instanceof Episode) {
return $data;
}
helper('id3');

Yassine Doghri
committed
write_audio_file_tags($episode);
return $data;
}