> */ 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', ], ]; /** * @var string */ protected $table = 'episodes'; /** * @var string */ protected $primaryKey = 'id'; /** * @var string[] */ protected $allowedFields = [ 'id', 'podcast_id', 'guid', 'title', 'slug', 'audio_file_path', 'audio_file_duration', 'audio_file_mimetype', 'audio_file_size', 'audio_file_header_size', 'description_markdown', 'description_html', 'image_path', 'image_mimetype', 'transcript_file_path', 'transcript_file_remote_url', 'chapters_file_path', 'chapters_file_remote_url', 'parental_advisory', 'number', 'season_number', 'type', 'is_blocked', 'location_name', 'location_geo', 'location_osm', 'custom_rss', 'favourites_total', 'reblogs_total', 'notes_total', 'published_at', 'created_by', 'updated_by', ]; /** * @var string */ protected $returnType = Episode::class; /** * @var bool */ protected $useSoftDeletes = true; /** * @var bool */ protected $useTimestamps = true; /** * @var array */ protected $validationRules = [ 'podcast_id' => 'required', 'title' => 'required', 'slug' => 'required|regex_match[/^[a-zA-Z0-9\-]{1,191}$/]', 'audio_file_path' => 'required', 'description_markdown' => 'required', 'number' => 'is_natural_no_zero|permit_empty', 'season_number' => 'is_natural_no_zero|permit_empty', 'type' => 'required', 'transcript_file_remote_url' => 'valid_url|permit_empty', 'chapters_file_remote_url' => 'valid_url|permit_empty', 'published_at' => 'valid_date|permit_empty', 'created_by' => 'required', 'updated_by' => 'required', ]; /** * @var string[] */ protected $afterInsert = ['writeEnclosureMetadata', 'clearCache']; // clear cache beforeUpdate because if slug changes, so will the episode link /** * @var string[] */ protected $beforeUpdate = ['clearCache']; /** * @var string[] */ protected $afterUpdate = ['writeEnclosureMetadata']; /** * @var string[] */ protected $beforeDelete = ['clearCache']; /** * @param int|string $podcastId may be the id or podcast name */ public function getEpisodeBySlug(int|string $podcastId, string $episodeSlug): ?Episode { $cacheName = "podcast#{$podcastId}_episode-{$episodeSlug}"; if (!($found = cache($cacheName))) { $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($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 { $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_published"; if (!($found = cache($cacheName))) { $found = $this->where([ 'id' => $episodeId, ]) ->where('podcast_id', $podcastId) ->where('`published_at` <= NOW()', null, false) ->first(); cache()->save($cacheName, $found, DECADE); } return $found; } /** * Gets all episodes for a podcast ordered according to podcast type * Filtered depending on year or season * * @return 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; } /** * 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 * * @return int|bool seconds */ public function getSecondsToNextUnpublishedEpisode(int $podcastId): int|bool { $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 count($result) !== 0 ? (int) $result[0]['timestamp_diff'] : false; } /** * @param mixed[] $data * * @return array> */ public function clearCache(array $data): array { $episode = (new EpisodeModel())->find( is_array($data['id']) ? $data['id'][0] : $data['id'], ); // delete cache for rss feed cache()->deleteMatching("podcast#{$episode->podcast_id}_feed*"); // delete model requests cache cache()->delete("podcast#{$episode->podcast_id}_episodes"); cache()->delete("podcast_episode#{$episode->id}"); cache()->deleteMatching( "podcast#{$episode->podcast_id}_episode#{$episode->id}*", ); cache()->delete( "podcast#{$episode->podcast_id}_episode-{$episode->slug}", ); cache()->deleteMatching( "page_podcast#{$episode->podcast_id}_activity*", ); cache()->deleteMatching( "page_podcast#{$episode->podcast_id}_episode#{$episode->id}_*", ); cache()->deleteMatching('page_credits_*'); if ($episode->season_number) { cache()->deleteMatching("podcast#{$episode->podcast_id}_season*"); cache()->deleteMatching( "page_podcast#{$episode->podcast_id}_episodes_season*", ); } else { cache()->deleteMatching("podcast#{$episode->podcast_id}_year*"); cache()->deleteMatching( "page_podcast#{$episode->podcast_id}_episodes_year*", ); } // 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; } /** * @param mixed[] $data * * @return array> */ protected function writeEnclosureMetadata(array $data): array { helper('id3'); $episode = (new EpisodeModel())->find( is_array($data['id']) ? $data['id'][0] : $data['id'], ); write_audio_file_tags($episode); return $data; } }