Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • adaures/castopod
  • mkljczk/castopod-host
  • spaetz/castopod-host
  • PatrykMis/castopod
  • jonas/castopod
  • ajeremias/castopod
  • misuzu/castopod
  • KrzysztofDomanczyk/castopod
  • Behel/castopod
  • nebulon/castopod
  • ewen/castopod
  • NeoluxConsulting/castopod
  • nateritter/castopod-og
  • prcutler/castopod
14 results
Show changes
Commits on Source (2)
Showing
with 245 additions and 69 deletions
# [1.0.0-alpha.8](https://code.podlibre.org/podlibre/castopod/compare/v1.0.0-alpha.7...v1.0.0-alpha.8) (2020-10-22)
### Features
* **episodes:** schedule episode with future publication_date by using cache expiration time ([4f1e773](https://code.podlibre.org/podlibre/castopod/commit/4f1e773c0f9e4c2597f6c1b0a4773dfb34b2f203)), closes [#47](https://code.podlibre.org/podlibre/castopod/issues/47)
# [1.0.0-alpha.7](https://code.podlibre.org/podlibre/castopod/compare/v1.0.0-alpha.6...v1.0.0-alpha.7) (2020-10-21)
......
......@@ -37,6 +37,8 @@ Javascript dependencies:
([Free amCharts license](https://github.com/amcharts/amcharts4/blob/master/dist/script/LICENSE))
- [Choices.js](https://joshuajohnson.co.uk/Choices/)
([MIT License](https://github.com/jshjohnson/Choices/blob/master/LICENSE))
- [flatpickr](https://flatpickr.js.org/)
([MIT License](https://github.com/flatpickr/flatpickr/blob/master/LICENSE.md))
Other:
......
......@@ -26,7 +26,7 @@ class BaseController extends Controller
*
* @var array
*/
protected $helpers = ['auth', 'breadcrumb', 'svg', 'components'];
protected $helpers = ['auth', 'breadcrumb', 'svg', 'components', 'misc'];
/**
* Constructor.
......
......@@ -10,6 +10,7 @@ namespace App\Controllers\Admin;
use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use CodeIgniter\I18n\Time;
class Episode extends BaseController
{
......@@ -95,9 +96,7 @@ class Episode extends BaseController
'enclosure' => 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]',
'image' =>
'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
'publication_date' => 'valid_date[Y-m-d]|permit_empty',
'publication_time' =>
'regex_match[/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/]|permit_empty',
'publication_date' => 'valid_date[Y-m-d H:i]|permit_empty',
];
if (!$this->validate($rules)) {
......@@ -125,11 +124,12 @@ class Episode extends BaseController
'block' => $this->request->getPost('block') == 'yes',
'created_by' => user(),
'updated_by' => user(),
'published_at' => Time::createFromFormat(
'Y-m-d H:i',
$this->request->getPost('publication_date'),
$this->request->getPost('client_timezone')
)->setTimezone('UTC'),
]);
$newEpisode->setPublishedAt(
$this->request->getPost('publication_date'),
$this->request->getPost('publication_time')
);
$episodeModel = new EpisodeModel();
......@@ -185,9 +185,7 @@ class Episode extends BaseController
'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]|permit_empty',
'image' =>
'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
'publication_date' => 'valid_date[Y-m-d]|permit_empty',
'publication_time' =>
'regex_match[/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/]|permit_empty',
'publication_date' => 'valid_date[Y-m-d H:i]|permit_empty',
];
if (!$this->validate($rules)) {
......@@ -210,10 +208,11 @@ class Episode extends BaseController
: null;
$this->episode->type = $this->request->getPost('type');
$this->episode->block = $this->request->getPost('block') == 'yes';
$this->episode->setPublishedAt(
$this->episode->published_at = Time::createFromFormat(
'Y-m-d H:i',
$this->request->getPost('publication_date'),
$this->request->getPost('publication_time')
);
$this->request->getPost('client_timezone')
)->setTimezone('UTC');
$this->episode->updated_by = user();
$enclosure = $this->request->getFile('enclosure');
......
......@@ -388,11 +388,8 @@ class Podcast extends BaseController
: $nsItunes->block === 'yes',
'created_by' => user(),
'updated_by' => user(),
'published_at' => strtotime($item->pubDate),
]);
$newEpisode->setPublishedAt(
date('Y-m-d', strtotime($item->pubDate)),
date('H:i:s', strtotime($item->pubDate))
);
$episodeModel = new EpisodeModel();
......
......@@ -26,7 +26,7 @@ class BaseController extends Controller
*
* @var array
*/
protected $helpers = ['analytics', 'svg', 'components'];
protected $helpers = ['analytics', 'svg', 'components', 'misc'];
/**
* Constructor.
......
......@@ -48,7 +48,8 @@ class Episode extends BaseController
$cacheName = "page_podcast{$this->episode->podcast_id}_episode{$this->episode->id}_{$locale}";
if (!($cachedView = cache($cacheName))) {
$previousNextEpisodes = (new EpisodeModel())->getPreviousNextEpisodes(
$episodeModel = new EpisodeModel();
$previousNextEpisodes = $episodeModel->getPreviousNextEpisodes(
$this->episode,
$this->podcast->type
);
......@@ -60,9 +61,15 @@ class Episode extends BaseController
'episode' => $this->episode,
];
$secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode(
$this->podcast->id
);
// The page cache is set to a decade so it is deleted manually upon podcast update
return view('episode', $data, [
'cache' => DECADE,
'cache' => $secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE,
'cache_name' => $cacheName,
]);
}
......
......@@ -8,6 +8,7 @@
namespace App\Controllers;
use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use CodeIgniter\Controller;
......@@ -31,15 +32,29 @@ class Feed extends Controller
// If things go wrong the show must go on and the user must be able to download the file
log_message('critical', $e);
}
$cacheName =
"podcast{$podcast->id}_feed" .
($service ? "_{$service['slug']}" : '');
if (!($found = cache($cacheName))) {
$found = get_rss_feed(
$podcast,
$service ? '?s=' . urlencode($service['name']) : ''
);
cache()->save($cacheName, $found, DECADE);
// The page cache is set to expire after next episode publication or a decade by default so it is deleted manually upon podcast update
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$podcast->id
);
cache()->save(
$cacheName,
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
}
return $this->response->setXML($found);
}
......
......@@ -48,7 +48,7 @@ class Install extends Controller
}
// Check if the created .env file is writable to continue install process
if (is_writable(ROOTPATH . '.env')) {
if (is_really_writable(ROOTPATH . '.env')) {
try {
$dotenv->required([
'app.baseURL',
......
......@@ -113,7 +113,7 @@ class Podcast extends BaseController
'podcast' => $this->podcast,
'episodesNav' => $episodesNavigation,
'activeQuery' => $activeQuery,
'episodes' => (new EpisodeModel())->getPodcastEpisodes(
'episodes' => $episodeModel->getPodcastEpisodes(
$this->podcast->id,
$this->podcast->type,
$yearQuery,
......@@ -121,8 +121,14 @@ class Podcast extends BaseController
),
];
$secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode(
$this->podcast->id
);
return view('podcast', $data, [
'cache' => DECADE,
'cache' => $secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE,
'cache_name' => $cacheName,
]);
}
......
......@@ -10,6 +10,7 @@ namespace App\Entities;
use App\Models\PodcastModel;
use CodeIgniter\Entity;
use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter;
class Episode extends Entity
......@@ -49,6 +50,11 @@ class Episode extends Entity
*/
protected $description_html;
/**
* @var boolean
*/
protected $is_published;
protected $dates = [
'published_at',
'created_at',
......@@ -232,17 +238,6 @@ class Episode extends Entity
return $converter->convertToHtml($this->attributes['description']);
}
public function setPublishedAt($date, $time)
{
if (empty($date)) {
$this->attributes['published_at'] = null;
} else {
$this->attributes['published_at'] = $date . ' ' . $time;
}
return $this;
}
public function setCreatedBy(\App\Entities\User $user)
{
$this->attributes['created_by'] = $user->id;
......@@ -256,4 +251,17 @@ class Episode extends Entity
return $this;
}
public function getIsPublished()
{
if ($this->is_published) {
return $this->is_published;
}
helper('date');
$this->is_published = $this->published_at->isBefore(Time::now());
return $this->is_published;
}
}
......@@ -256,3 +256,51 @@ if (!function_exists('data_table')) {
}
// ------------------------------------------------------------------------
if (!function_exists('publication_pill')) {
/**
* Data table component
*
* Creates a stylized table.
*
* @param \CodeIgniter\I18n\Time $publicationDate publication datetime of the episode
* @param boolean $isPublished whether or not the episode has been published
* @param string $customClass css class to add to the component
*
* @return string
*/
function publication_pill(
$publicationDate,
$isPublished,
$customClass = ''
): string {
$class = $isPublished
? 'text-green-500 border-green-500'
: 'text-orange-600 border-orange-600';
$label = lang(
$isPublished ? 'Episode.published' : 'Episode.scheduled',
[
'<time
pubdate
datetime="' .
$publicationDate->format(DateTime::ATOM) .
'"
title="' .
$publicationDate .
'">' .
lang('Common.mediumDate', [$publicationDate]) .
'</time>',
]
);
return '<span class="px-1 border ' .
$class .
' ' .
$customClass .
'">' .
$label .
'</span>';
}
}
// ------------------------------------------------------------------------
......@@ -143,3 +143,27 @@ function slugify($text)
return $text;
}
//--------------------------------------------------------------------
if (!function_exists('format_duration')) {
/**
* Formats duration in seconds to an hh:mm:ss string
*
* @param int $seconds seconds to format
* @param string $separator
*
* @return string
*/
function format_duration($seconds, $separator = ':')
{
return sprintf(
'%02d%s%02d%s%02d',
floor($seconds / 3600),
$separator,
($seconds / 60) % 60,
$separator,
$seconds % 60
);
}
}
......@@ -14,7 +14,6 @@ return [
'home' => 'Home',
'explicit' => 'Explicit',
'mediumDate' => '{0,date,medium}',
'duration' => '{0,duration}',
'powered_by' => 'Powered by {castopod}.',
'actions' => 'Actions',
'pageInfo' => 'Page {currentPage} out of {pageCount}',
......
......@@ -22,6 +22,8 @@ return [
'delete' => 'Delete',
'go_to_page' => 'Go to page',
'create' => 'Add an episode',
'published' => 'Published on {0}',
'scheduled' => 'Scheduled for {0}',
'form' => [
'enclosure' => 'Audio file',
'enclosure_hint' => 'Choose an .mp3 or .m4a audio file.',
......@@ -54,11 +56,9 @@ return [
'This text is added at the end of each episode description, it is a good place to input your social links for example.',
'publication_section_title' => 'Publication info',
'publication_section_subtitle' => '',
'published_at' => [
'label' => 'Publication date',
'date' => 'Date',
'time' => 'Time',
],
'publication_date' => 'Publication date',
'publication_date_hint' =>
'You can schedule the episode release by setting a future publication date. This field must be formatted as YYYY-MM-DD HH:mm',
'parental_advisory' => [
'label' => 'Parental advisory',
'hint' => 'Does the episode contain explicit content?',
......
......@@ -14,7 +14,6 @@ return [
'home' => 'Accueil',
'explicit' => 'Explicite',
'mediumDate' => '{0,date,medium}',
'duration' => '{0,duration}',
'powered_by' => 'Propulsé par {castopod}.',
'actions' => 'Actions',
'pageInfo' => 'Page {currentPage} sur {pageCount}',
......
......@@ -22,6 +22,8 @@ return [
'delete' => 'Supprimer',
'go_to_page' => 'Voir',
'create' => 'Ajouter un épisode',
'published' => 'Publié le {0}',
'scheduled' => 'Planifié pour le {0}',
'form' => [
'enclosure' => 'Fichier audio',
'enclosure_hint' => 'Sélectionnez un fichier audio .mp3 ou .m4a.',
......@@ -54,11 +56,9 @@ return [
'Ce texte est ajouté à la fin de chaque description d’épisode, c’est un bon endroit pour placer vos liens sociaux par exemple.',
'publication_section_title' => 'Information de publication',
'publication_section_subtitle' => '',
'published_at' => [
'label' => 'Date de publication',
'date' => 'Date',
'time' => 'Heure',
],
'publication_date' => 'Date de publication',
'publication_date_hint' =>
'Vous pouvez planifier la sortie de l’épisode en saisissant une date de publication future. Ce champ doit être au format YYYY-MM-DD HH:mm',
'parental_advisory' => [
'label' => 'Avertissement parental',
'hint' => 'L’épisode contient-il un contenu explicite ?',
......
......@@ -12,6 +12,7 @@ return [
'messages' => [
'wrongPasswordError' =>
'Le mot de passe que vous avez saisi est invalide.',
'passwordChangeSuccess' => 'Le mot de passe a été modifié avec succès !',
'passwordChangeSuccess' =>
'Le mot de passe a été modifié avec succès !',
],
];
......@@ -57,32 +57,21 @@ class EpisodeModel extends Model
];
protected $validationMessages = [];
protected $afterInsert = ['writeEnclosureMetadata'];
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'];
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 getEpisodeBySlug($podcastId, $episodeSlug)
{
if (!($found = cache("podcast{$podcastId}_episode@{$episodeSlug}"))) {
$found = $this->where([
'podcast_id' => $podcastId,
'slug' => $episodeSlug,
])->first();
])
->where('`published_at` <= NOW()', null, false)
->first();
cache()->save(
"podcast{$podcastId}_episode@{$episodeSlug}",
......@@ -120,6 +109,7 @@ class EpisodeModel extends Model
'podcast_id' => $episode->podcast_id,
$sortNumberField . ' <' => $sortNumberValue,
])
->where('`published_at` <= NOW()', null, false)
->first();
$nextData = $this->orderBy('(' . $sortNumberField . ') ASC')
......@@ -127,6 +117,7 @@ class EpisodeModel extends Model
'podcast_id' => $episode->podcast_id,
$sortNumberField . ' >' => $sortNumberValue,
])
->where('`published_at` <= NOW()', null, false)
->first();
return [
......@@ -160,7 +151,9 @@ class EpisodeModel extends Model
);
if (!($found = cache($cacheName))) {
$where = ['podcast_id' => $podcastId];
$where = [
'podcast_id' => $podcastId,
];
if ($year) {
$where['YEAR(published_at)'] = $year;
$where['season_number'] = null;
......@@ -172,15 +165,27 @@ class EpisodeModel extends Model
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();
}
cache()->save($cacheName, $found, DECADE);
$secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
$podcastId
);
cache()->save(
$cacheName,
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
}
return $found;
......@@ -197,12 +202,23 @@ class EpisodeModel extends Model
'season_number' => null,
$this->deletedField => null,
])
->where('`published_at` <= NOW()', null, false)
->groupBy('year')
->orderBy('year', 'DESC')
->get()
->getResultArray();
cache()->save("podcast{$podcastId}_years", $found, DECADE);
$secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
$podcastId
);
cache()->save(
"podcast{$podcastId}_years",
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
}
return $found;
......@@ -219,12 +235,23 @@ class EpisodeModel extends Model
'season_number is not' => null,
$this->deletedField => null,
])
->where('`published_at` <= NOW()', null, false)
->groupBy('season_number')
->orderBy('season_number', 'ASC')
->get()
->getResultArray();
cache()->save("podcast{$podcastId}_seasons", $found, DECADE);
$secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
$podcastId
);
cache()->save(
"podcast{$podcastId}_seasons",
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
}
return $found;
......@@ -264,6 +291,43 @@ class EpisodeModel extends Model
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;
}
protected function clearCache(array $data)
{
$episodeModel = new EpisodeModel();
......
......@@ -59,7 +59,7 @@ class PodcastModel extends Model
];
protected $validationMessages = [];
// clear cache before update if by any chance, the podcast name changes, and so will the podcast link
// clear cache before update if by any chance, the podcast name changes, so will the podcast link
protected $beforeUpdate = ['clearCache'];
protected $beforeDelete = ['clearCache'];
......