feat(import): run podcast imports' processes asynchronously using tasks
- use codeigniter4/tasks project to handle cron tasks - use yassinedoghri/podcast-feed project to parse feeds for imports
|
@ -43,23 +43,24 @@ class Autoload extends AutoloadConfig
|
|||
*/
|
||||
public $psr4 = [
|
||||
APP_NAMESPACE => APPPATH,
|
||||
'Config' => APPPATH . 'Config/',
|
||||
'Modules' => ROOTPATH . 'modules/',
|
||||
'Modules\Admin' => ROOTPATH . 'modules/Admin/',
|
||||
'Modules\Auth' => ROOTPATH . 'modules/Auth/',
|
||||
'Modules\Analytics' => ROOTPATH . 'modules/Analytics/',
|
||||
'Modules\Install' => ROOTPATH . 'modules/Install/',
|
||||
'Modules\Update' => ROOTPATH . 'modules/Update/',
|
||||
'Modules\Fediverse' => ROOTPATH . 'modules/Fediverse/',
|
||||
'Modules\Media' => ROOTPATH . 'modules/Media/',
|
||||
'Modules\WebSub' => ROOTPATH . 'modules/WebSub/',
|
||||
'Modules\Api\Rest\V1' => ROOTPATH . 'modules/Api/Rest/V1',
|
||||
'Modules\Auth' => ROOTPATH . 'modules/Auth/',
|
||||
'Modules\Fediverse' => ROOTPATH . 'modules/Fediverse/',
|
||||
'Modules\Install' => ROOTPATH . 'modules/Install/',
|
||||
'Modules\Media' => ROOTPATH . 'modules/Media/',
|
||||
'Modules\MediaClipper' => ROOTPATH . 'modules/MediaClipper/',
|
||||
'Modules\PodcastImport' => ROOTPATH . 'modules/PodcastImport/',
|
||||
'Modules\PremiumPodcasts' => ROOTPATH . 'modules/PremiumPodcasts/',
|
||||
'Config' => APPPATH . 'Config/',
|
||||
'Modules\Update' => ROOTPATH . 'modules/Update/',
|
||||
'Modules\WebSub' => ROOTPATH . 'modules/WebSub/',
|
||||
'Themes' => ROOTPATH . 'themes',
|
||||
'ViewComponents' => APPPATH . 'Libraries/ViewComponents/',
|
||||
'ViewThemes' => APPPATH . 'Libraries/ViewThemes/',
|
||||
'MediaClipper' => APPPATH . 'Libraries/MediaClipper/',
|
||||
'Vite' => APPPATH . 'Libraries/Vite/',
|
||||
'Themes' => ROOTPATH . 'themes',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
use CodeIgniter\Tasks\Scheduler;
|
||||
|
||||
class Tasks extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Should performance metrics be logged
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* If true, will log the time it takes for each task to run.
|
||||
* Requires the settings table to have been created previously.
|
||||
*/
|
||||
public bool $logPerformance = false;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Maximum performance logs
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The maximum number of logs that should be saved per Task.
|
||||
* Lower numbers reduced the amount of database required to
|
||||
* store the logs.
|
||||
*/
|
||||
public int $maxLogsPerTask = 10;
|
||||
|
||||
/**
|
||||
* Register any tasks within this method for the application.
|
||||
* Called by the TaskRunner.
|
||||
*/
|
||||
public function init(Scheduler $schedule): void
|
||||
{
|
||||
$schedule->command('fediverse:broadcast')
|
||||
->everyMinute()
|
||||
->named('fediverse-broadcast');
|
||||
|
||||
$schedule->command('websub:publish')
|
||||
->everyMinute()
|
||||
->named('websub-publish');
|
||||
|
||||
$schedule->command('video-clips:generate')
|
||||
->everyMinute()
|
||||
->named('video-clips-generate');
|
||||
|
||||
$schedule->command('podcast:import')
|
||||
->everyMinute()
|
||||
->named('podcast-import');
|
||||
}
|
||||
}
|
|
@ -89,11 +89,11 @@ class WebmanifestController extends Controller
|
|||
|
||||
$webmanifest = [
|
||||
'name' => esc($podcast->title),
|
||||
'short_name' => '@' . esc($podcast->handle),
|
||||
'short_name' => $podcast->at_handle,
|
||||
'description' => $podcast->description,
|
||||
'lang' => $podcast->language_code,
|
||||
'start_url' => $podcast->link,
|
||||
'scope' => '/@' . esc($podcast->handle),
|
||||
'scope' => '/' . $podcast->at_handle,
|
||||
'display' => 'standalone',
|
||||
'orientation' => 'portrait',
|
||||
'theme_color' => self::THEME_COLORS[service('settings')->get('App.theme')]['theme'],
|
||||
|
|
|
@ -129,15 +129,15 @@ class BaseClip extends Entity
|
|||
$this->getMedia()
|
||||
->setFile($file);
|
||||
$this->getMedia()
|
||||
->updated_by = (int) user_id();
|
||||
->updated_by = $this->attributes['updated_by'];
|
||||
(new MediaModel('audio'))->updateMedia($this->getMedia());
|
||||
} else {
|
||||
$media = new Audio([
|
||||
'file_key' => $fileKey,
|
||||
'language_code' => $this->getPodcast()
|
||||
->language_code,
|
||||
'uploaded_by' => $this->attributes['created_by'],
|
||||
'updated_by' => $this->attributes['created_by'],
|
||||
'uploaded_by' => $this->attributes['updated_by'],
|
||||
'updated_by' => $this->attributes['updated_by'],
|
||||
]);
|
||||
$media->setFile($file);
|
||||
|
||||
|
|
|
@ -188,15 +188,15 @@ class Episode extends Entity
|
|||
$this->getCover()
|
||||
->setFile($file);
|
||||
$this->getCover()
|
||||
->updated_by = (int) user_id();
|
||||
->updated_by = $this->attributes['updated_by'];
|
||||
(new MediaModel('image'))->updateMedia($this->getCover());
|
||||
} else {
|
||||
$cover = new Image([
|
||||
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '.' . $file->getExtension(),
|
||||
'sizes' => config('Images')
|
||||
->podcastCoverSizes,
|
||||
'uploaded_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'uploaded_by' => $this->attributes['updated_by'],
|
||||
'updated_by' => $this->attributes['updated_by'],
|
||||
]);
|
||||
$cover->setFile($file);
|
||||
|
||||
|
@ -234,7 +234,7 @@ class Episode extends Entity
|
|||
$this->getAudio()
|
||||
->setFile($file);
|
||||
$this->getAudio()
|
||||
->updated_by = (int) user_id();
|
||||
->updated_by = $this->attributes['updated_by'];
|
||||
(new MediaModel('audio'))->updateMedia($this->getAudio());
|
||||
} else {
|
||||
$audio = new Audio([
|
||||
|
@ -244,8 +244,8 @@ class Episode extends Entity
|
|||
) . '.' . $file->getExtension(),
|
||||
'language_code' => $this->getPodcast()
|
||||
->language_code,
|
||||
'uploaded_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'uploaded_by' => $this->attributes['updated_by'],
|
||||
'updated_by' => $this->attributes['updated_by'],
|
||||
]);
|
||||
$audio->setFile($file);
|
||||
|
||||
|
@ -274,15 +274,15 @@ class Episode extends Entity
|
|||
$this->getTranscript()
|
||||
->setFile($file);
|
||||
$this->getTranscript()
|
||||
->updated_by = (int) user_id();
|
||||
->updated_by = $this->attributes['updated_by'];
|
||||
(new MediaModel('transcript'))->updateMedia($this->getTranscript());
|
||||
} else {
|
||||
$transcript = new Transcript([
|
||||
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-transcript.' . $file->getExtension(),
|
||||
'language_code' => $this->getPodcast()
|
||||
->language_code,
|
||||
'uploaded_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'uploaded_by' => $this->attributes['updated_by'],
|
||||
'updated_by' => $this->attributes['updated_by'],
|
||||
]);
|
||||
$transcript->setFile($file);
|
||||
|
||||
|
@ -311,15 +311,15 @@ class Episode extends Entity
|
|||
$this->getChapters()
|
||||
->setFile($file);
|
||||
$this->getChapters()
|
||||
->updated_by = (int) user_id();
|
||||
->updated_by = $this->attributes['updated_by'];
|
||||
(new MediaModel('chapters'))->updateMedia($this->getChapters());
|
||||
} else {
|
||||
$chapters = new Chapters([
|
||||
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-chapters' . '.' . $file->getExtension(),
|
||||
'language_code' => $this->getPodcast()
|
||||
->language_code,
|
||||
'uploaded_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'uploaded_by' => $this->attributes['updated_by'],
|
||||
'updated_by' => $this->attributes['updated_by'],
|
||||
]);
|
||||
$chapters->setFile($file);
|
||||
|
||||
|
|
|
@ -66,15 +66,15 @@ class Person extends Entity
|
|||
$this->getAvatar()
|
||||
->setFile($file);
|
||||
$this->getAvatar()
|
||||
->updated_by = (int) user_id();
|
||||
->updated_by = $this->attributes['updated_by'];
|
||||
(new MediaModel('image'))->updateMedia($this->getAvatar());
|
||||
} else {
|
||||
$avatar = new Image([
|
||||
'file_key' => 'persons/' . $this->attributes['unique_name'] . '.' . $file->getExtension(),
|
||||
'sizes' => config('Images')
|
||||
->personAvatarSizes,
|
||||
'uploaded_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'uploaded_by' => $this->attributes['updated_by'],
|
||||
'updated_by' => $this->attributes['updated_by'],
|
||||
]);
|
||||
$avatar->setFile($file);
|
||||
|
||||
|
|
|
@ -41,6 +41,7 @@ use RuntimeException;
|
|||
* @property int $actor_id
|
||||
* @property Actor|null $actor
|
||||
* @property string $handle
|
||||
* @property string $at_handle
|
||||
* @property string $link
|
||||
* @property string $feed_url
|
||||
* @property string $title
|
||||
|
@ -240,15 +241,15 @@ class Podcast extends Entity
|
|||
$this->getCover()
|
||||
->setFile($file);
|
||||
$this->getCover()
|
||||
->updated_by = (int) user_id();
|
||||
->updated_by = $this->attributes['updated_by'];
|
||||
(new MediaModel('image'))->updateMedia($this->getCover());
|
||||
} else {
|
||||
$cover = new Image([
|
||||
'file_key' => 'podcasts/' . $this->attributes['handle'] . '/cover.' . $file->getExtension(),
|
||||
'sizes' => config('Images')
|
||||
->podcastCoverSizes,
|
||||
'uploaded_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'uploaded_by' => $this->attributes['updated_by'],
|
||||
'updated_by' => $this->attributes['updated_by'],
|
||||
]);
|
||||
$cover->setFile($file);
|
||||
|
||||
|
@ -283,15 +284,15 @@ class Podcast extends Entity
|
|||
$this->getBanner()
|
||||
->setFile($file);
|
||||
$this->getBanner()
|
||||
->updated_by = (int) user_id();
|
||||
->updated_by = $this->attributes['updated_by'];
|
||||
(new MediaModel('image'))->updateMedia($this->getBanner());
|
||||
} else {
|
||||
$banner = new Image([
|
||||
'file_key' => 'podcasts/' . $this->attributes['handle'] . '/banner.' . $file->getExtension(),
|
||||
'sizes' => config('Images')
|
||||
->podcastBannerSizes,
|
||||
'uploaded_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'uploaded_by' => $this->attributes['updated_by'],
|
||||
'updated_by' => $this->attributes['updated_by'],
|
||||
]);
|
||||
$banner->setFile($file);
|
||||
|
||||
|
|
|
@ -218,8 +218,8 @@ if (! function_exists('publication_status_banner')) {
|
|||
}
|
||||
|
||||
return <<<HTML
|
||||
<div class="flex items-center px-12 py-1 border-b bg-stripes-gray border-subtle" role="alert">
|
||||
<p class="text-gray-900">
|
||||
<div class="flex items-center px-12 py-2 border-b bg-stripes-gray border-subtle" role="alert">
|
||||
<p class="flex items-center text-gray-900">
|
||||
<span class="text-xs font-semibold tracking-wide uppercase">{$bannerDisclaimer}</span>
|
||||
<span class="ml-3 text-sm">{$bannerText}</span>
|
||||
</p>
|
||||
|
|
|
@ -207,22 +207,6 @@ if (! function_exists('format_duration_symbol')) {
|
|||
|
||||
//--------------------------------------------------------------------
|
||||
|
||||
if (! function_exists('podcast_uuid')) {
|
||||
/**
|
||||
* Generate UUIDv5 for podcast. For more information, see
|
||||
* https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#guid
|
||||
*/
|
||||
function podcast_uuid(string $feedUrl): string
|
||||
{
|
||||
$uuid = service('uuid');
|
||||
// 'ead4c236-bf58-58c6-a2c6-a6b28d128cb6' is the uuid of the podcast namespace
|
||||
return $uuid->uuid5('ead4c236-bf58-58c6-a2c6-a6b28d128cb6', $feedUrl)
|
||||
->toString();
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------
|
||||
|
||||
if (! function_exists('generate_random_salt')) {
|
||||
function generate_random_salt(int $length = 64): string
|
||||
{
|
||||
|
|
|
@ -283,7 +283,7 @@ class EpisodeModel extends Model
|
|||
public function getCurrentSeasonNumber(int $podcastId): ?int
|
||||
{
|
||||
$result = $this->builder()
|
||||
->select('MAX(season_number) as current_season_number')
|
||||
->selectMax('season_number', 'current_season_number')
|
||||
->where([
|
||||
'podcast_id' => $podcastId,
|
||||
'published_at IS NOT' => null,
|
||||
|
@ -297,7 +297,7 @@ class EpisodeModel extends Model
|
|||
public function getNextEpisodeNumber(int $podcastId, ?int $seasonNumber): int
|
||||
{
|
||||
$result = $this->builder()
|
||||
->select('MAX(number) as next_episode_number')
|
||||
->selectMax('number', 'next_episode_number')
|
||||
->where([
|
||||
'podcast_id' => $podcastId,
|
||||
'season_number' => $seasonNumber,
|
||||
|
@ -466,6 +466,19 @@ class EpisodeModel extends Model
|
|||
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) . ')
|
||||
';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed[] $data
|
||||
*
|
||||
|
@ -494,17 +507,4 @@ class EpisodeModel extends Model
|
|||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function getFullTextMatchClauseForEpisodes(string $table, string $value): string
|
||||
{
|
||||
return '
|
||||
MATCH (
|
||||
' . $table . '.title,
|
||||
' . $table . '.description_markdown,
|
||||
' . $table . '.slug,
|
||||
' . $table . '.location_name
|
||||
)
|
||||
AGAINST("*' . preg_replace('/[^\p{L}\p{N}_]+/u', ' ', $value) . '*" IN BOOLEAN MODE)
|
||||
';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -190,12 +190,13 @@ class PersonModel extends Model
|
|||
public function addPerson(string $fullName, ?string $informationUrl, string $image): int | bool
|
||||
{
|
||||
$person = new Person([
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'full_name' => $fullName,
|
||||
'unique_name' => slugify($fullName),
|
||||
'information_url' => $informationUrl,
|
||||
'image' => download_file($image),
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
|
||||
]);
|
||||
|
||||
return $this->insert($person);
|
||||
|
@ -267,6 +268,7 @@ class PersonModel extends Model
|
|||
public function addPodcastPerson(int $podcastId, int $personId, string $groupSlug, string $roleSlug): bool
|
||||
{
|
||||
return $this->db->table('podcasts_persons')
|
||||
->ignore(true)
|
||||
->insert([
|
||||
'podcast_id' => $podcastId,
|
||||
'person_id' => $personId,
|
||||
|
|
|
@ -177,18 +177,6 @@ class PlatformModel extends Model
|
|||
->insertBatch($podcastsPlatformsData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed[] $podcastsPlatformsData
|
||||
*/
|
||||
public function createPodcastPlatforms(int $podcastId, array $podcastsPlatformsData): int | false
|
||||
{
|
||||
$this->clearCache($podcastId);
|
||||
|
||||
return $this->db
|
||||
->table('podcasts_platforms')
|
||||
->insertBatch($podcastsPlatformsData);
|
||||
}
|
||||
|
||||
public function removePodcastPlatform(int $podcastId, string $platformSlug): bool | string
|
||||
{
|
||||
$this->clearCache($podcastId);
|
||||
|
|
|
@ -393,7 +393,7 @@ class PodcastModel extends Model
|
|||
' . $table . '.handle,
|
||||
' . $table . '.location_name
|
||||
)
|
||||
AGAINST("*' . preg_replace('/[^\p{L}\p{N}_]+/u', ' ', $value) . '*" IN BOOLEAN MODE)
|
||||
AGAINST(' . $this->db->escape($value) . ')
|
||||
';
|
||||
}
|
||||
|
||||
|
@ -499,13 +499,18 @@ class PodcastModel extends Model
|
|||
/**
|
||||
* @param mixed[] $data
|
||||
*
|
||||
* Sets the UUIDv5 for a podcast. For more information, see
|
||||
* https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#guid
|
||||
*
|
||||
* @return mixed[]
|
||||
*/
|
||||
protected function setPodcastGUID(array $data): array
|
||||
{
|
||||
if (! array_key_exists('guid', $data['data']) || $data['data']['guid'] === null) {
|
||||
helper('misc');
|
||||
$data['data']['guid'] = podcast_uuid(url_to('podcast-rss-feed', $data['data']['handle']));
|
||||
$uuid = service('uuid');
|
||||
$feedUrl = url_to('podcast-rss-feed', $data['data']['handle']);
|
||||
// 'ead4c236-bf58-58c6-a2c6-a6b28d128cb6' is the uuid of the podcast namespace
|
||||
$data['data']['guid'] = $uuid->uuid5('ead4c236-bf58-58c6-a2c6-a6b28d128cb6', $feedUrl)->toString();
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM11 15V17H13V15H11ZM11 7V13H13V7H11Z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 239 B |
|
@ -217,7 +217,7 @@
|
|||
.choices__list--dropdown {
|
||||
@apply z-50 border-2 shadow-lg border-contrast;
|
||||
|
||||
visibility: hidden;
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
background-color: hsl(var(--color-background-elevated));
|
||||
|
@ -225,11 +225,10 @@
|
|||
margin-top: -1px;
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
will-change: visibility;
|
||||
}
|
||||
|
||||
.choices__list--dropdown.is-active {
|
||||
visibility: visible;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.is-open .choices__list--dropdown {
|
||||
|
|
|
@ -57,4 +57,14 @@
|
|||
#e5e7eb 20px
|
||||
);
|
||||
}
|
||||
|
||||
.bg-stripes-warning {
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
#fde047,
|
||||
#fde047 10px,
|
||||
#facc15 10px,
|
||||
#facc15 20px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,14 +32,24 @@ class Alert extends Component
|
|||
'class' => 'text-yellow-900 bg-yellow-100 border-yellow-300',
|
||||
'glyph' => 'alert',
|
||||
],
|
||||
'default' => [
|
||||
'class' => 'text-blue-900 bg-blue-100 border-blue-300',
|
||||
'glyph' => 'error-warning',
|
||||
],
|
||||
];
|
||||
|
||||
$glyph = '<Icon glyph="' . ($this->glyph === null ? $variants[$this->variant]['glyph'] : $this->glyph) . '" class="flex-shrink-0 mr-2 text-lg" />';
|
||||
if (! array_key_exists($this->variant, $variants)) {
|
||||
$this->variant = 'default';
|
||||
}
|
||||
|
||||
$glyph = icon(($this->glyph === null ? $variants[$this->variant]['glyph'] : $this->glyph), 'flex-shrink-0 mr-2 text-lg');
|
||||
$title = $this->title === null ? '' : '<div class="font-semibold">' . $this->title . '</div>';
|
||||
$class = 'inline-flex w-full p-2 text-sm border rounded ' . $variants[$this->variant]['class'] . ' ' . $this->class;
|
||||
|
||||
unset($this->attributes['slot']);
|
||||
unset($this->attributes['variant']);
|
||||
unset($this->attributes['class']);
|
||||
unset($this->attributes['glyph']);
|
||||
$attributes = stringify_attributes($this->attributes);
|
||||
|
||||
return <<<HTML
|
||||
|
|
|
@ -22,9 +22,7 @@ class Checkbox extends FormComponent
|
|||
'name' => $this->name,
|
||||
'class' => 'form-checkbox bg-elevated text-accent-base border-contrast border-3 focus:ring-accent w-6 h-6',
|
||||
];
|
||||
if ($this->required) {
|
||||
$attributes['required'] = 'required';
|
||||
}
|
||||
|
||||
$checkboxInput = form_checkbox(
|
||||
$attributes,
|
||||
'yes',
|
||||
|
|
|
@ -39,6 +39,10 @@ class FormComponent extends Component
|
|||
public function setRequired(string $value): void
|
||||
{
|
||||
$this->required = $value === 'true';
|
||||
unset($this->attributes['required']);
|
||||
if ($this->required) {
|
||||
$this->attributes['required'] = 'required';
|
||||
}
|
||||
}
|
||||
|
||||
public function setReadonly(string $value): void
|
||||
|
|
|
@ -20,12 +20,6 @@ class Input extends FormComponent
|
|||
$this->attributes['class'] .= ' px-3 py-2';
|
||||
}
|
||||
|
||||
unset($this->attributes['required']);
|
||||
|
||||
if ($this->required) {
|
||||
$this->attributes['required'] = 'required';
|
||||
}
|
||||
|
||||
return form_input($this->attributes, old($this->name, $this->value));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,11 +12,9 @@ class Section extends Component
|
|||
|
||||
protected ?string $subtitle = null;
|
||||
|
||||
protected string $subtitleClass = '';
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
$subtitle = $this->subtitle === null ? '' : '<p class="text-sm text-skin-muted ' . $this->subtitleClass . '">' . $this->subtitle . '</p>';
|
||||
$subtitle = $this->subtitle === null ? '' : '<p class="text-sm text-skin-muted">' . $this->subtitle . '</p>';
|
||||
|
||||
return <<<HTML
|
||||
<fieldset class="w-full p-8 bg-elevated border-3 flex flex-col items-start border-subtle rounded-xl {$this->class}">
|
||||
|
|
|
@ -29,6 +29,9 @@ class Select extends FormComponent
|
|||
'data-no-choices-text' => lang('Common.forms.multiSelect.noChoicesText'),
|
||||
'data-max-item-text' => lang('Common.forms.multiSelect.maxItemText'),
|
||||
];
|
||||
unset($this->attributes['name']);
|
||||
unset($this->attributes['options']);
|
||||
unset($this->attributes['selected']);
|
||||
$extra = array_merge($this->attributes, $defaultAttributes);
|
||||
|
||||
return form_dropdown($this->name, $this->options, old($this->name, $this->selected !== '' ? [$this->selected] : []), $extra);
|
||||
|
|
|
@ -24,7 +24,9 @@
|
|||
"melbahja/seo": "^v2.1.1",
|
||||
"codeigniter4/shield": "v1.0.0-beta.3",
|
||||
"aws/aws-sdk-php": "^3.273.2",
|
||||
"mpratt/embera": "^2.0.33"
|
||||
"mpratt/embera": "^2.0.33",
|
||||
"codeigniter4/tasks": "dev-develop",
|
||||
"yassinedoghri/podcast-feed": "dev-main"
|
||||
},
|
||||
"require-dev": {
|
||||
"mikey179/vfsstream": "^v1.6.11",
|
||||
|
@ -80,5 +82,11 @@
|
|||
"allow-plugins": {
|
||||
"phpstan/extension-installer": true
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/codeigniter4/tasks.git"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -477,6 +477,82 @@
|
|||
},
|
||||
"time": "2022-10-30T23:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "codeigniter4/tasks",
|
||||
"version": "dev-develop",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/codeigniter4/tasks.git",
|
||||
"reference": "7e1ffe22f5aec609325a9a1fafa401f703cddd71"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/codeigniter4/tasks/zipball/7e1ffe22f5aec609325a9a1fafa401f703cddd71",
|
||||
"reference": "7e1ffe22f5aec609325a9a1fafa401f703cddd71",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"codeigniter4/settings": "^2.0",
|
||||
"ext-json": "*",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"codeigniter4/devkit": "^1.0",
|
||||
"codeigniter4/framework": "^4.1",
|
||||
"rector/rector": "0.17.0"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"CodeIgniter\\Tasks\\": "src"
|
||||
},
|
||||
"exclude-from-classmap": ["**/Database/Migrations/**"]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\Support\\": "tests/_support"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-update-cmd": ["bash admin/setup.sh"],
|
||||
"analyze": ["phpstan analyze", "psalm"],
|
||||
"ci": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@deduplicate",
|
||||
"@analyze",
|
||||
"@test",
|
||||
"@inspect",
|
||||
"rector process",
|
||||
"@style"
|
||||
],
|
||||
"deduplicate": ["phpcpd app/ src/"],
|
||||
"inspect": ["deptrac analyze --cache-file=build/deptrac.cache"],
|
||||
"mutate": [
|
||||
"infection --threads=2 --skip-initial-tests --coverage=build/phpunit"
|
||||
],
|
||||
"retool": ["retool"],
|
||||
"style": ["php-cs-fixer fix --verbose --ansi --using-cache=no"],
|
||||
"cs-fix": ["@style"],
|
||||
"test": ["phpunit"]
|
||||
},
|
||||
"license": ["MIT"],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Lonnie Ezell",
|
||||
"email": "lonnieje@gmail.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Task Scheduler for CodeIgniter 4",
|
||||
"homepage": "https://github.com/codeigniter4/tasks",
|
||||
"keywords": ["codeigniter", "codeigniter4", "cron", "task scheduling"],
|
||||
"support": {
|
||||
"source": "https://github.com/codeigniter4/tasks/tree/develop",
|
||||
"issues": "https://github.com/codeigniter4/tasks/issues"
|
||||
},
|
||||
"time": "2023-06-02T11:03:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/ca-bundle",
|
||||
"version": "1.3.6",
|
||||
|
@ -3052,6 +3128,55 @@
|
|||
"source": "https://github.com/WhichBrowser/Parser-PHP/tree/v2.1.7"
|
||||
},
|
||||
"time": "2022-04-19T20:14:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "yassinedoghri/podcast-feed",
|
||||
"version": "dev-main",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/yassinedoghri/podcast-feed.git",
|
||||
"reference": "c6b25fb19d6d14f93e403e423640df7714067aca"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/yassinedoghri/podcast-feed/zipball/c6b25fb19d6d14f93e403e423640df7714067aca",
|
||||
"reference": "c6b25fb19d6d14f93e403e423640df7714067aca",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-intl": "*",
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"kint-php/kint": "^5.0.5",
|
||||
"phpstan/phpstan": "^1.10.18",
|
||||
"rector/rector": "^0.17.0",
|
||||
"symplify/coding-standard": "^11.3.0",
|
||||
"symplify/easy-coding-standard": "^11.3.4"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PodcastFeed\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": ["AGPL-3.0-or-later"],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Yassine Doghri",
|
||||
"email": "yassine@doghri.fr",
|
||||
"homepage": "https://yassinedoghri.com",
|
||||
"role": "Maintainer"
|
||||
}
|
||||
],
|
||||
"description": "A robust podcast feed parser and validator written in PHP.",
|
||||
"support": {
|
||||
"issues": "https://github.com/yassinedoghri/podcast-feed/issues",
|
||||
"source": "https://github.com/yassinedoghri/podcast-feed/tree/main"
|
||||
},
|
||||
"time": "2023-06-11T16:54:30+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [
|
||||
|
@ -6460,7 +6585,9 @@
|
|||
"stability-flags": {
|
||||
"james-heinrich/getid3": 10,
|
||||
"michalsn/codeigniter4-uuid": 20,
|
||||
"codeigniter4/shield": 10
|
||||
"codeigniter4/shield": 10,
|
||||
"codeigniter4/tasks": 20,
|
||||
"yassinedoghri/podcast-feed": 20
|
||||
},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
|
|
4
crontab
|
@ -1,3 +1 @@
|
|||
* * * * * /usr/local/bin/php /castopod/public/index.php scheduled-activities
|
||||
* * * * * /usr/local/bin/php /castopod/public/index.php scheduled-video-clips
|
||||
* * * * * /usr/local/bin/php /castopod/public/index.php scheduled-websub-publish
|
||||
* * * * * /usr/local/bin/php /castopod/spark tasks:run >> /dev/null 2>&1
|
||||
|
|
|
@ -1,3 +1 @@
|
|||
* * * * * /usr/local/bin/php /var/www/castopod/public/index.php scheduled-activities
|
||||
* * * * * /usr/local/bin/php /var/www/castopod/public/index.php scheduled-websub-publish
|
||||
* * * * * /usr/local/bin/php /var/www/castopod/public/index.php scheduled-video-clips
|
||||
* * * * * /usr/local/bin/php /var/www/castopod/spark tasks:run >> /dev/null 2>&1
|
||||
|
|
|
@ -94,29 +94,19 @@ want to generate Video Clips. The following extensions must be installed:
|
|||
4. Add **cron tasks** on your web server for various background processes
|
||||
(replace the paths accordingly):
|
||||
|
||||
- For social features to work properly, this task is used to broadcast social
|
||||
activities to your followers on the fediverse:
|
||||
|
||||
```bash
|
||||
* * * * * /path/to/php /path/to/castopod/public/index.php scheduled-activities
|
||||
* * * * * /path/to/php /path/to/castopod/spark tasks:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
- For having your episodes be broadcasted on open hubs upon publication using
|
||||
[WebSub](https://en.wikipedia.org/wiki/WebSub):
|
||||
**Note** - If you do not add this cron task, the following Castopod features
|
||||
will not work:
|
||||
|
||||
```bash
|
||||
* * * * * /usr/local/bin/php /castopod/public/index.php scheduled-websub-publish
|
||||
```
|
||||
|
||||
- For Video Clips to be created (see
|
||||
[FFmpeg requirements](#ffmpeg-v418-or-higher-for-video-clips)):
|
||||
|
||||
```bash
|
||||
* * * * * /path/to/php /path/to/castopod/public/index.php scheduled-video-clips
|
||||
```
|
||||
|
||||
> These tasks run **every minute**. You may set the frequency depending on
|
||||
> your needs: every 5, 10 minutes or more.
|
||||
- Importing a podcast from an existing RSS feed
|
||||
- Broadcasting social activities to your followers in the fediverse
|
||||
- Broadcasting episodes to open hubs using
|
||||
[WebSub](https://en.wikipedia.org/wiki/WebSub)
|
||||
- Generating video clips -
|
||||
[requires FFmpeg](#optional-ffmpeg-v418-or-higher-for-video-clips)
|
||||
|
||||
### (recommended) Install Wizard
|
||||
|
||||
|
|
|
@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Modules\Admin\Config;
|
||||
|
||||
use CodeIgniter\Router\RouteCollection;
|
||||
|
||||
/** @var RouteCollection $routes */
|
||||
$routes = service('routes');
|
||||
|
||||
// video-clips scheduler
|
||||
|
@ -94,13 +97,6 @@ $routes->group(
|
|||
$routes->post('new', 'PodcastController::attemptCreate', [
|
||||
'filter' => 'permission:podcasts.create',
|
||||
]);
|
||||
$routes->get('import', 'PodcastImportController', [
|
||||
'as' => 'podcast-import',
|
||||
'filter' => 'permission:podcasts.import',
|
||||
]);
|
||||
$routes->post('import', 'PodcastImportController::attemptImport', [
|
||||
'filter' => 'permission:podcasts.import',
|
||||
]);
|
||||
// Podcast
|
||||
// Use ids in admin area to help permission and group lookups
|
||||
$routes->group('(:num)', static function ($routes): void {
|
||||
|
@ -164,10 +160,6 @@ $routes->group(
|
|||
$routes->post('delete', 'PodcastController::attemptDelete/$1', [
|
||||
'filter' => 'permission:podcast#.delete',
|
||||
]);
|
||||
$routes->get('update', 'PodcastImportController::updateImport/$1', [
|
||||
'as' => 'podcast-update-feed',
|
||||
'filter' => 'permission:podcast#.manage-import',
|
||||
]);
|
||||
$routes->group('persons', static function ($routes): void {
|
||||
$routes->get('/', 'PodcastPersonController/$1', [
|
||||
'as' => 'podcast-persons-manage',
|
||||
|
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Modules\Admin\Controllers;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
@ -21,6 +22,13 @@ use ViewThemes\Theme;
|
|||
|
||||
abstract class BaseController extends Controller
|
||||
{
|
||||
/**
|
||||
* Instance of the main Request object.
|
||||
*
|
||||
* @var IncomingRequest
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
|
|
|
@ -23,7 +23,7 @@ class DashboardController extends BaseController
|
|||
$podcastsCount = (new PodcastModel())->builder()
|
||||
->countAll();
|
||||
$podcastsLastPublishedAt = (new PodcastModel())->builder()
|
||||
->select('MAX(published_at) as last_published_at')
|
||||
->selectMax('published_at', 'last_published_at')
|
||||
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
|
||||
->get()
|
||||
->getResultArray()[0]['last_published_at'];
|
||||
|
@ -36,7 +36,7 @@ class DashboardController extends BaseController
|
|||
$episodesCount = (new EpisodeModel())->builder()
|
||||
->countAll();
|
||||
$episodesLastPublishedAt = (new EpisodeModel())->builder()
|
||||
->select('MAX(published_at) as last_published_at')
|
||||
->selectMax('published_at', 'last_published_at')
|
||||
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
|
||||
->get()
|
||||
->getResultArray()[0]['last_published_at'];
|
||||
|
|
|
@ -20,6 +20,7 @@ use App\Models\EpisodeModel;
|
|||
use App\Models\PodcastModel;
|
||||
use App\Models\PostModel;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
use CodeIgniter\HTTP\RedirectResponse;
|
||||
use CodeIgniter\I18n\Time;
|
||||
use Modules\Media\Entities\Chapters;
|
||||
|
@ -68,29 +69,36 @@ class EpisodeController extends BaseController
|
|||
/** @var ?string $query */
|
||||
$query = $this->request->getGet('q');
|
||||
|
||||
$episodeModel = new EpisodeModel();
|
||||
if ($query !== null && $query !== '') {
|
||||
// Default value for MySQL Full-Text Search's minimum length of words is 4.
|
||||
// Use LIKE operator as a fallback.
|
||||
if (strlen($query) < 4) {
|
||||
$episodes = (new EpisodeModel())
|
||||
$episodes = $episodeModel
|
||||
->select('episodes.*, IFNULL(SUM(ape.hits),0) as downloads')
|
||||
->join('analytics_podcasts_by_episode ape', 'episodes.id=ape.episode_id', 'left')
|
||||
->where('episodes.podcast_id', $this->podcast->id)
|
||||
->like('title', $query)
|
||||
->orLike('description_markdown', $query)
|
||||
->like('title', $episodeModel->db->escapeLikeString($query))
|
||||
->orLike('description_markdown', $episodeModel->db->escapeLikeString($query))
|
||||
->orLike('slug', $episodeModel->db->escapeLikeString($query))
|
||||
->orLike('location_name', $episodeModel->db->escapeLikeString($query))
|
||||
->groupBy('episodes.id')
|
||||
->orderBy('-`published_at`', '', false)
|
||||
->orderBy('created_at', 'desc');
|
||||
} else {
|
||||
$episodes = (new EpisodeModel())
|
||||
$episodes = $episodeModel
|
||||
->select('episodes.*, IFNULL(SUM(ape.hits),0) as downloads')
|
||||
->join('analytics_podcasts_by_episode ape', 'episodes.id=ape.episode_id', 'left')
|
||||
->where('episodes.podcast_id', $this->podcast->id)
|
||||
->where("MATCH (title, description_markdown, slug, location_name) AGAINST ('{$query}')")
|
||||
->where(
|
||||
"MATCH (title, description_markdown, slug, location_name) AGAINST ('{$episodeModel->db->escapeString(
|
||||
$query
|
||||
)}')"
|
||||
)
|
||||
->groupBy('episodes.id');
|
||||
}
|
||||
} else {
|
||||
$episodes = (new EpisodeModel())
|
||||
$episodes = $episodeModel
|
||||
->select('episodes.*, IFNULL(SUM(ape.hits),0) as downloads')
|
||||
->join('analytics_podcasts_by_episode ape', 'episodes.id=ape.episode_id', 'left')
|
||||
->where('episodes.podcast_id', $this->podcast->id)
|
||||
|
@ -183,6 +191,8 @@ class EpisodeController extends BaseController
|
|||
$db->transStart();
|
||||
|
||||
$newEpisode = new Episode([
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'podcast_id' => $this->podcast->id,
|
||||
'title' => $this->request->getPost('title'),
|
||||
'slug' => $this->request->getPost('slug'),
|
||||
|
@ -208,8 +218,6 @@ class EpisodeController extends BaseController
|
|||
'is_blocked' => $this->request->getPost('block') === 'yes',
|
||||
'custom_rss_string' => $this->request->getPost('custom_rss'),
|
||||
'is_premium' => $this->request->getPost('premium') === 'yes',
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
|
@ -333,7 +341,7 @@ class EpisodeController extends BaseController
|
|||
$transcriptChoice = $this->request->getPost('transcript-choice');
|
||||
if ($transcriptChoice === 'upload-file') {
|
||||
$transcriptFile = $this->request->getFile('transcript_file');
|
||||
if ($transcriptFile !== null && $transcriptFile->isValid()) {
|
||||
if ($transcriptFile instanceof UploadedFile && $transcriptFile->isValid()) {
|
||||
$this->episode->setTranscript($transcriptFile);
|
||||
$this->episode->transcript_remote_url = null;
|
||||
}
|
||||
|
@ -351,7 +359,7 @@ class EpisodeController extends BaseController
|
|||
$chaptersChoice = $this->request->getPost('chapters-choice');
|
||||
if ($chaptersChoice === 'upload-file') {
|
||||
$chaptersFile = $this->request->getFile('chapters_file');
|
||||
if ($chaptersFile !== null && $chaptersFile->isValid()) {
|
||||
if ($chaptersFile instanceof UploadedFile && $chaptersFile->isValid()) {
|
||||
$this->episode->setChapters($chaptersFile);
|
||||
$this->episode->chapters_remote_url = null;
|
||||
}
|
||||
|
|
|
@ -79,12 +79,12 @@ class PersonController extends BaseController
|
|||
$db->transStart();
|
||||
|
||||
$person = new Person([
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'full_name' => $this->request->getPost('full_name'),
|
||||
'unique_name' => $this->request->getPost('unique_name'),
|
||||
'information_url' => $this->request->getPost('information_url'),
|
||||
'avatar' => $this->request->getFile('avatar'),
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
]);
|
||||
|
||||
$personModel = new PersonModel();
|
||||
|
@ -129,13 +129,12 @@ class PersonController extends BaseController
|
|||
->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$this->person->updated_by = user_id();
|
||||
$this->person->full_name = $this->request->getPost('full_name');
|
||||
$this->person->unique_name = $this->request->getPost('unique_name');
|
||||
$this->person->information_url = $this->request->getPost('information_url');
|
||||
$this->person->setAvatar($this->request->getFile('avatar'));
|
||||
|
||||
$this->person->updated_by = user_id();
|
||||
|
||||
$personModel = new PersonModel();
|
||||
if (! $personModel->update($this->person->id, $this->person)) {
|
||||
return redirect()
|
||||
|
|
|
@ -210,6 +210,8 @@ class PodcastController extends BaseController
|
|||
$db->transStart();
|
||||
|
||||
$newPodcast = new Podcast([
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'title' => $this->request->getPost('title'),
|
||||
'handle' => $this->request->getPost('handle'),
|
||||
'cover' => $this->request->getFile('cover'),
|
||||
|
@ -239,8 +241,6 @@ class PodcastController extends BaseController
|
|||
'is_completed' => $this->request->getPost('complete') === 'yes',
|
||||
'is_locked' => $this->request->getPost('lock') === 'yes',
|
||||
'is_premium_by_default' => $this->request->getPost('premium_by_default') === 'yes',
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
|
@ -319,6 +319,8 @@ class PodcastController extends BaseController
|
|||
$partnerImageUrl = null;
|
||||
}
|
||||
|
||||
$this->podcast->updated_by = (int) user_id();
|
||||
|
||||
$this->podcast->title = $this->request->getPost('title');
|
||||
$this->podcast->description_markdown = $this->request->getPost('description');
|
||||
$this->podcast->setCover($this->request->getFile('cover'));
|
||||
|
@ -353,7 +355,6 @@ class PodcastController extends BaseController
|
|||
$this->request->getPost('complete') === 'yes';
|
||||
$this->podcast->is_locked = $this->request->getPost('lock') === 'yes';
|
||||
$this->podcast->is_premium_by_default = $this->request->getPost('premium_by_default') === 'yes';
|
||||
$this->podcast->updated_by = (int) user_id();
|
||||
|
||||
// republish on websub hubs upon edit
|
||||
$this->podcast->is_published_on_hubs = false;
|
||||
|
|
|
@ -1,701 +0,0 @@
|
|||
<?php
|
||||
|
||||
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 Modules\Admin\Controllers;
|
||||
|
||||
use AdAures\PodcastPersonsTaxonomy\ReversedTaxonomy;
|
||||
use App\Entities\Episode;
|
||||
use App\Entities\Location;
|
||||
use App\Entities\Person;
|
||||
use App\Entities\Platform;
|
||||
use App\Entities\Podcast;
|
||||
use App\Models\CategoryModel;
|
||||
use App\Models\EpisodeModel;
|
||||
use App\Models\LanguageModel;
|
||||
use App\Models\PersonModel;
|
||||
use App\Models\PlatformModel;
|
||||
use App\Models\PodcastModel;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
use CodeIgniter\HTTP\RedirectResponse;
|
||||
use ErrorException;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
|
||||
class PodcastImportController extends BaseController
|
||||
{
|
||||
protected ?Podcast $podcast;
|
||||
|
||||
public function _remap(string $method, string ...$params): mixed
|
||||
{
|
||||
if ($params === []) {
|
||||
return $this->{$method}();
|
||||
}
|
||||
|
||||
if (($this->podcast = (new PodcastModel())->getPodcastById((int) $params[0])) instanceof Podcast) {
|
||||
return $this->{$method}();
|
||||
}
|
||||
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
public function index(): string
|
||||
{
|
||||
helper(['form', 'misc']);
|
||||
|
||||
$languageOptions = (new LanguageModel())->getLanguageOptions();
|
||||
$categoryOptions = (new CategoryModel())->getCategoryOptions();
|
||||
|
||||
$data = [
|
||||
'languageOptions' => $languageOptions,
|
||||
'categoryOptions' => $categoryOptions,
|
||||
'browserLang' => get_browser_language($this->request->getServer('HTTP_ACCEPT_LANGUAGE')),
|
||||
];
|
||||
|
||||
return view('podcast/import', $data);
|
||||
}
|
||||
|
||||
public function attemptImport(): RedirectResponse
|
||||
{
|
||||
helper(['media', 'misc']);
|
||||
|
||||
$rules = [
|
||||
'handle' => 'required|regex_match[/^[a-zA-Z0-9\_]{1,32}$/]',
|
||||
'imported_feed_url' => 'required|valid_url_strict',
|
||||
'season_number' => 'is_natural_no_zero|permit_empty',
|
||||
'max_episodes' => 'is_natural_no_zero|permit_empty',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
try {
|
||||
ini_set('user_agent', 'Castopod/' . CP_VERSION);
|
||||
$feed = simplexml_load_file($this->request->getPost('imported_feed_url'));
|
||||
} catch (ErrorException $errorException) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', [
|
||||
$errorException->getMessage() .
|
||||
': <a href="' .
|
||||
$this->request->getPost('imported_feed_url') .
|
||||
'" rel="noreferrer noopener" target="_blank">' .
|
||||
$this->request->getPost('imported_feed_url') .
|
||||
' ⎋</a>',
|
||||
]);
|
||||
}
|
||||
|
||||
$nsItunes = $feed->channel[0]->children('http://www.itunes.com/dtds/podcast-1.0.dtd');
|
||||
$nsPodcast = $feed->channel[0]->children(
|
||||
'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md',
|
||||
);
|
||||
$nsContent = $feed->channel[0]->children('http://purl.org/rss/1.0/modules/content/');
|
||||
|
||||
if ((string) $nsPodcast->locked === 'yes') {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', [lang('PodcastImport.lock_import')]);
|
||||
}
|
||||
|
||||
$converter = new HtmlConverter();
|
||||
|
||||
$channelDescriptionHtml = (string) $feed->channel[0]->description;
|
||||
|
||||
try {
|
||||
if (
|
||||
property_exists($nsItunes, 'image') && $nsItunes->image !== null &&
|
||||
$nsItunes->image->attributes()['href'] !== null
|
||||
) {
|
||||
$coverFile = download_file((string) $nsItunes->image->attributes()['href']);
|
||||
} else {
|
||||
$coverFile = download_file((string) $feed->channel[0]->image->url);
|
||||
}
|
||||
|
||||
$location = null;
|
||||
if (property_exists($nsPodcast, 'location') && $nsPodcast->location !== null) {
|
||||
$location = new Location(
|
||||
(string) $nsPodcast->location,
|
||||
$nsPodcast->location->attributes()['geo'] === null ? null : (string) $nsPodcast->location->attributes()['geo'],
|
||||
$nsPodcast->location->attributes()['osm'] === null ? null : (string) $nsPodcast->location->attributes()['osm'],
|
||||
);
|
||||
}
|
||||
|
||||
$guid = null;
|
||||
if (property_exists($nsPodcast, 'guid') && $nsPodcast->guid !== null) {
|
||||
$guid = (string) $nsPodcast->guid;
|
||||
}
|
||||
|
||||
$db = db_connect();
|
||||
$db->transStart();
|
||||
|
||||
$podcast = new Podcast([
|
||||
'guid' => $guid,
|
||||
'handle' => $this->request->getPost('handle'),
|
||||
'imported_feed_url' => $this->request->getPost('imported_feed_url'),
|
||||
'new_feed_url' => url_to('podcast-rss-feed', $this->request->getPost('handle')),
|
||||
'title' => (string) $feed->channel[0]->title,
|
||||
'description_markdown' => $converter->convert($channelDescriptionHtml),
|
||||
'description_html' => $channelDescriptionHtml,
|
||||
'cover' => $coverFile,
|
||||
'banner' => null,
|
||||
'language_code' => $this->request->getPost('language'),
|
||||
'category_id' => $this->request->getPost('category'),
|
||||
'parental_advisory' => property_exists($nsItunes, 'explicit') && $nsItunes->explicit !== null
|
||||
? (in_array((string) $nsItunes->explicit, ['yes', 'true'], true)
|
||||
? 'explicit'
|
||||
: (in_array((string) $nsItunes->explicit, ['no', 'false'], true)
|
||||
? 'clean'
|
||||
: null))
|
||||
: null,
|
||||
'owner_name' => (string) $nsItunes->owner->name,
|
||||
'owner_email' => (string) $nsItunes->owner->email,
|
||||
'publisher' => (string) $nsItunes->author,
|
||||
'type' => property_exists(
|
||||
$nsItunes,
|
||||
'type'
|
||||
) && $nsItunes->type !== null ? (string) $nsItunes->type : 'episodic',
|
||||
'copyright' => (string) $feed->channel[0]->copyright,
|
||||
'is_blocked' => property_exists(
|
||||
$nsItunes,
|
||||
'block'
|
||||
) && $nsItunes->block !== null && (string) $nsItunes->block === 'yes',
|
||||
'is_completed' => property_exists(
|
||||
$nsItunes,
|
||||
'complete'
|
||||
) && $nsItunes->complete !== null && (string) $nsItunes->complete === 'yes',
|
||||
'location' => $location,
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
]);
|
||||
} catch (ErrorException $errorException) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', [
|
||||
$errorException->getMessage() .
|
||||
': <a href="' .
|
||||
$this->request->getPost('imported_feed_url') .
|
||||
'" rel="noreferrer noopener" target="_blank">' .
|
||||
$this->request->getPost('imported_feed_url') .
|
||||
' ⎋</a>',
|
||||
]);
|
||||
}
|
||||
|
||||
$podcastModel = new PodcastModel();
|
||||
if (! ($newPodcastId = $podcastModel->insert($podcast, true))) {
|
||||
$db->transRollback();
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', $podcastModel->errors());
|
||||
}
|
||||
|
||||
// set current user as podcast admin
|
||||
// 1. create new group
|
||||
config('AuthGroups')
|
||||
->generatePodcastAuthorizations($newPodcastId);
|
||||
add_podcast_group(auth()->user(), $newPodcastId, 'admin');
|
||||
|
||||
$podcastsPlatformsData = [];
|
||||
$platformTypes = [
|
||||
[
|
||||
'name' => 'podcasting',
|
||||
'elements' => $nsPodcast->id,
|
||||
'account_url_key' => 'url',
|
||||
'account_id_key' => 'id',
|
||||
],
|
||||
[
|
||||
'name' => 'social',
|
||||
'elements' => $nsPodcast->social,
|
||||
'account_url_key' => 'accountUrl',
|
||||
'account_id_key' => 'accountId',
|
||||
],
|
||||
[
|
||||
'name' => 'funding',
|
||||
'elements' => $nsPodcast->funding,
|
||||
'account_url_key' => 'url',
|
||||
'account_id_key' => 'id',
|
||||
],
|
||||
];
|
||||
$platformModel = new PlatformModel();
|
||||
foreach ($platformTypes as $platformType) {
|
||||
foreach ($platformType['elements'] as $platform) {
|
||||
$platformLabel = $platform->attributes()['platform'];
|
||||
$platformSlug = slugify((string) $platformLabel);
|
||||
if ($platformModel->getPlatform($platformSlug) instanceof Platform) {
|
||||
$podcastsPlatformsData[] = [
|
||||
'platform_slug' => $platformSlug,
|
||||
'podcast_id' => $newPodcastId,
|
||||
'link_url' => $platform->attributes()[$platformType['account_url_key']],
|
||||
'account_id' => $platform->attributes()[$platformType['account_id_key']],
|
||||
'is_visible' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count($podcastsPlatformsData) > 1) {
|
||||
$platformModel->createPodcastPlatforms($newPodcastId, $podcastsPlatformsData);
|
||||
}
|
||||
|
||||
foreach ($nsPodcast->person as $podcastPerson) {
|
||||
$fullName = (string) $podcastPerson;
|
||||
$personModel = new PersonModel();
|
||||
$newPersonId = null;
|
||||
if (($newPerson = $personModel->getPerson($fullName)) instanceof Person) {
|
||||
$newPersonId = $newPerson->id;
|
||||
} else {
|
||||
$newPodcastPerson = new Person([
|
||||
'full_name' => $fullName,
|
||||
'unique_name' => slugify($fullName),
|
||||
'information_url' => $podcastPerson->attributes()['href'],
|
||||
'avatar' => download_file((string) $podcastPerson->attributes()['img']),
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
]);
|
||||
|
||||
if (! $newPersonId = $personModel->insert($newPodcastPerson)) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', $personModel->errors());
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: these checks should be in the taxonomy as default values
|
||||
$podcastPersonGroup = $podcastPerson->attributes()['group'] ?? 'Cast';
|
||||
$podcastPersonRole = $podcastPerson->attributes()['role'] ?? 'Host';
|
||||
|
||||
$personGroup = ReversedTaxonomy::$taxonomy[(string) $podcastPersonGroup];
|
||||
|
||||
$personGroupSlug = $personGroup['slug'];
|
||||
$personRoleSlug = $personGroup['roles'][(string) $podcastPersonRole]['slug'];
|
||||
|
||||
$podcastPersonModel = new PersonModel();
|
||||
if (! $podcastPersonModel->addPodcastPerson(
|
||||
$newPodcastId,
|
||||
$newPersonId,
|
||||
$personGroupSlug,
|
||||
$personRoleSlug
|
||||
)) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', $podcastPersonModel->errors());
|
||||
}
|
||||
}
|
||||
|
||||
$itemsCount = $feed->channel[0]->item->count();
|
||||
|
||||
$lastItem =
|
||||
$this->request->getPost('max_episodes') !== '' &&
|
||||
$this->request->getPost('max_episodes') < $itemsCount
|
||||
? (int) $this->request->getPost('max_episodes')
|
||||
: $itemsCount;
|
||||
|
||||
$slugs = [];
|
||||
for ($itemNumber = 1; $itemNumber <= $lastItem; ++$itemNumber) {
|
||||
$item = $feed->channel[0]->item[$itemsCount - $itemNumber];
|
||||
|
||||
$nsItunes = $item->children('http://www.itunes.com/dtds/podcast-1.0.dtd');
|
||||
$nsPodcast = $item->children(
|
||||
'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md',
|
||||
);
|
||||
$nsContent = $item->children('http://purl.org/rss/1.0/modules/content/');
|
||||
|
||||
$textToSlugify = $this->request->getPost('slug_field') === 'title'
|
||||
? (string) $item->title
|
||||
: basename((string) $item->link);
|
||||
$slug = slugify($textToSlugify, 120);
|
||||
if (in_array($slug, $slugs, true)) {
|
||||
$slugNumber = 2;
|
||||
while (in_array($slug . '-' . $slugNumber, $slugs, true)) {
|
||||
++$slugNumber;
|
||||
}
|
||||
|
||||
$slug = $slug . '-' . $slugNumber;
|
||||
}
|
||||
|
||||
$slugs[] = $slug;
|
||||
$itemDescriptionHtml = match ($this->request->getPost('description_field')) {
|
||||
'content' => (string) $nsContent->encoded,
|
||||
'summary' => (string) $nsItunes->summary,
|
||||
'subtitle_summary' => $nsItunes->subtitle . '<br/>' . $nsItunes->summary,
|
||||
default => (string) $item->description,
|
||||
};
|
||||
|
||||
if (
|
||||
property_exists($nsItunes, 'image') && $nsItunes->image !== null &&
|
||||
$nsItunes->image->attributes()['href'] !== null
|
||||
) {
|
||||
$episodeCover = download_file((string) $nsItunes->image->attributes()['href']);
|
||||
} else {
|
||||
$episodeCover = null;
|
||||
}
|
||||
|
||||
$location = null;
|
||||
if (property_exists($nsPodcast, 'location') && $nsPodcast->location !== null) {
|
||||
$location = new Location(
|
||||
(string) $nsPodcast->location,
|
||||
$nsPodcast->location->attributes()['geo'] === null ? null : (string) $nsPodcast->location->attributes()['geo'],
|
||||
$nsPodcast->location->attributes()['osm'] === null ? null : (string) $nsPodcast->location->attributes()['osm'],
|
||||
);
|
||||
}
|
||||
|
||||
$newEpisode = new Episode([
|
||||
'podcast_id' => $newPodcastId,
|
||||
'title' => $item->title,
|
||||
'slug' => $slug,
|
||||
'guid' => $item->guid ?? null,
|
||||
'audio' => download_file(
|
||||
(string) $item->enclosure->attributes()['url'],
|
||||
(string) $item->enclosure->attributes()['type']
|
||||
),
|
||||
'description_markdown' => $converter->convert($itemDescriptionHtml),
|
||||
'description_html' => $itemDescriptionHtml,
|
||||
'cover' => $episodeCover,
|
||||
'parental_advisory' => property_exists($nsItunes, 'explicit') && $nsItunes->explicit !== null
|
||||
? (in_array((string) $nsItunes->explicit, ['yes', 'true'], true)
|
||||
? 'explicit'
|
||||
: (in_array((string) $nsItunes->explicit, ['no', 'false'], true)
|
||||
? 'clean'
|
||||
: null))
|
||||
: null,
|
||||
'number' => $this->request->getPost('force_renumber') === 'yes'
|
||||
? $itemNumber
|
||||
: ((string) $nsItunes->episode === '' ? null : (int) $nsItunes->episode),
|
||||
'season_number' => $this->request->getPost('season_number') === ''
|
||||
? ((string) $nsItunes->season === '' ? null : (int) $nsItunes->season)
|
||||
: (int) $this->request->getPost('season_number'),
|
||||
'type' => property_exists($nsItunes, 'episodeType') && in_array(
|
||||
$nsItunes->episodeType,
|
||||
['trailer', 'full', 'bonus'],
|
||||
true
|
||||
)
|
||||
? (string) $nsItunes->episodeType
|
||||
: 'full',
|
||||
'is_blocked' => property_exists(
|
||||
$nsItunes,
|
||||
'block'
|
||||
) && $nsItunes->block !== null && (string) $nsItunes->block === 'yes',
|
||||
'location' => $location,
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'published_at' => strtotime((string) $item->pubDate),
|
||||
]);
|
||||
|
||||
$episodeModel = new EpisodeModel();
|
||||
|
||||
if (! ($newEpisodeId = $episodeModel->insert($newEpisode, true))) {
|
||||
// FIXME: What shall we do?
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', $episodeModel->errors());
|
||||
}
|
||||
|
||||
foreach ($nsPodcast->person as $episodePerson) {
|
||||
$fullName = (string) $episodePerson;
|
||||
$personModel = new PersonModel();
|
||||
$newPersonId = null;
|
||||
if (($newPerson = $personModel->getPerson($fullName)) instanceof Person) {
|
||||
$newPersonId = $newPerson->id;
|
||||
} else {
|
||||
$newPerson = new Person([
|
||||
'full_name' => $fullName,
|
||||
'unique_name' => slugify($fullName),
|
||||
'information_url' => $episodePerson->attributes()['href'],
|
||||
'avatar' => download_file((string) $episodePerson->attributes()['img']),
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
]);
|
||||
|
||||
if (! ($newPersonId = $personModel->insert($newPerson))) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', $personModel->errors());
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: these checks should be in the taxonomy as default values
|
||||
$episodePersonGroup = $episodePerson->attributes()['group'] ?? 'Cast';
|
||||
$episodePersonRole = $episodePerson->attributes()['role'] ?? 'Host';
|
||||
|
||||
$personGroup = ReversedTaxonomy::$taxonomy[(string) $episodePersonGroup];
|
||||
|
||||
$personGroupSlug = $personGroup['slug'];
|
||||
$personRoleSlug = $personGroup['roles'][(string) $episodePersonRole]['slug'];
|
||||
|
||||
$episodePersonModel = new PersonModel();
|
||||
if (! $episodePersonModel->addEpisodePerson(
|
||||
$newPodcastId,
|
||||
$newEpisodeId,
|
||||
$newPersonId,
|
||||
$personGroupSlug,
|
||||
$personRoleSlug
|
||||
)) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', $episodePersonModel->errors());
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemNumber === 1) {
|
||||
$firstEpisodePublicationDate = strtotime((string) $item->pubDate);
|
||||
}
|
||||
}
|
||||
|
||||
$importedPodcast = (new PodcastModel())->getPodcastById($newPodcastId);
|
||||
|
||||
// set podcast publication date
|
||||
$importedPodcast->published_at = $firstEpisodePublicationDate ?? $importedPodcast->created_at;
|
||||
$podcastModel = new PodcastModel();
|
||||
if (! $podcastModel->update($importedPodcast->id, $importedPodcast)) {
|
||||
$db->transRollback();
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', $podcastModel->errors());
|
||||
}
|
||||
|
||||
$db->transComplete();
|
||||
|
||||
return redirect()->route('podcast-view', [$newPodcastId]);
|
||||
}
|
||||
|
||||
public function updateImport(): RedirectResponse
|
||||
{
|
||||
if ($this->podcast->imported_feed_url === null) {
|
||||
return redirect()
|
||||
->back()
|
||||
->with('error', lang('Podcast.messages.podcastNotImported'));
|
||||
}
|
||||
|
||||
try {
|
||||
ini_set('user_agent', 'Castopod/' . CP_VERSION);
|
||||
$feed = simplexml_load_file($this->podcast->imported_feed_url);
|
||||
} catch (ErrorException $errorException) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', [
|
||||
$errorException->getMessage() .
|
||||
': <a href="' .
|
||||
$this->podcast->imported_feed_url .
|
||||
'" rel="noreferrer noopener" target="_blank">' .
|
||||
$this->podcast->imported_feed_url .
|
||||
' ⎋</a>',
|
||||
]);
|
||||
}
|
||||
|
||||
$nsPodcast = $feed->channel[0]->children(
|
||||
'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md',
|
||||
);
|
||||
|
||||
if ((string) $nsPodcast->locked === 'yes') {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', [lang('PodcastImport.lock_import')]);
|
||||
}
|
||||
|
||||
$itemsCount = $feed->channel[0]->item->count();
|
||||
|
||||
$lastItem = $itemsCount;
|
||||
|
||||
$lastEpisode = (new EpisodeModel())->where('podcast_id', $this->podcast->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastEpisode !== null) {
|
||||
for ($itemNumber = 0; $itemNumber < $itemsCount; ++$itemNumber) {
|
||||
$item = $feed->channel[0]->item[$itemNumber];
|
||||
|
||||
if (property_exists(
|
||||
$item,
|
||||
'guid'
|
||||
) && $item->guid !== null && $lastEpisode->guid === (string) $item->guid) {
|
||||
$lastItem = $itemNumber;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($lastItem === 0) {
|
||||
return redirect()
|
||||
->back()
|
||||
->with('message', lang('Podcast.messages.podcastFeedUpToDate'));
|
||||
}
|
||||
|
||||
helper(['media', 'misc']);
|
||||
|
||||
$converter = new HtmlConverter();
|
||||
|
||||
$slugs = [];
|
||||
|
||||
for ($itemNumber = 1; $itemNumber <= $lastItem; ++$itemNumber) {
|
||||
$db = db_connect();
|
||||
$db->transStart();
|
||||
|
||||
$item = $feed->channel[0]->item[$lastItem - $itemNumber];
|
||||
|
||||
$nsItunes = $item->children('http://www.itunes.com/dtds/podcast-1.0.dtd');
|
||||
$nsPodcast = $item->children(
|
||||
'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md',
|
||||
);
|
||||
|
||||
$textToSlugify = (string) $item->title;
|
||||
$slug = slugify($textToSlugify, 120);
|
||||
if (in_array($slug, $slugs, true) || (new EpisodeModel())->where([
|
||||
'slug' => $slug,
|
||||
'podcast_id' => $this->podcast->id,
|
||||
])->first()) {
|
||||
$slugNumber = 2;
|
||||
while (in_array($slug . '-' . $slugNumber, $slugs, true) || (new EpisodeModel())->where([
|
||||
'slug' => $slug . '-' . $slugNumber,
|
||||
'podcast_id' => $this->podcast->id,
|
||||
])->first()) {
|
||||
++$slugNumber;
|
||||
}
|
||||
|
||||
$slug = $slug . '-' . $slugNumber;
|
||||
}
|
||||
|
||||
$slugs[] = $slug;
|
||||
|
||||
$itemDescriptionHtml = (string) $item->description;
|
||||
|
||||
if (
|
||||
property_exists($nsItunes, 'image') && $nsItunes->image !== null &&
|
||||
$nsItunes->image->attributes()['href'] !== null
|
||||
) {
|
||||
$episodeCover = download_file((string) $nsItunes->image->attributes()['href']);
|
||||
} else {
|
||||
$episodeCover = null;
|
||||
}
|
||||
|
||||
$location = null;
|
||||
if (property_exists($nsPodcast, 'location') && $nsPodcast->location !== null) {
|
||||
$location = new Location(
|
||||
(string) $nsPodcast->location,
|
||||
$nsPodcast->location->attributes()['geo'] === null ? null : (string) $nsPodcast->location->attributes()['geo'],
|
||||
$nsPodcast->location->attributes()['osm'] === null ? null : (string) $nsPodcast->location->attributes()['osm'],
|
||||
);
|
||||
}
|
||||
|
||||
$newEpisode = new Episode([
|
||||
'podcast_id' => $this->podcast->id,
|
||||
'title' => $item->title,
|
||||
'slug' => $slug,
|
||||
'guid' => $item->guid ?? null,
|
||||
'audio' => download_file(
|
||||
(string) $item->enclosure->attributes()['url'],
|
||||
(string) $item->enclosure->attributes()['type']
|
||||
),
|
||||
'description_markdown' => $converter->convert($itemDescriptionHtml),
|
||||
'description_html' => $itemDescriptionHtml,
|
||||
'cover' => $episodeCover,
|
||||
'parental_advisory' => property_exists($nsItunes, 'explicit') && $nsItunes->explicit !== null
|
||||
? (in_array((string) $nsItunes->explicit, ['yes', 'true'], true)
|
||||
? 'explicit'
|
||||
: (in_array((string) $nsItunes->explicit, ['no', 'false'], true)
|
||||
? 'clean'
|
||||
: null))
|
||||
: null,
|
||||
'number' => ((string) $nsItunes->episode === '' ? null : (int) $nsItunes->episode),
|
||||
'season_number' => ((string) $nsItunes->season === '' ? null : (int) $nsItunes->season),
|
||||
'type' => property_exists($nsItunes, 'episodeType') && $nsItunes->episodeType !== null
|
||||
? (string) $nsItunes->episodeType
|
||||
: 'full',
|
||||
'is_blocked' => property_exists(
|
||||
$nsItunes,
|
||||
'block'
|
||||
) && $nsItunes->block !== null && (string) $nsItunes->block === 'yes',
|
||||
'location' => $location,
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'published_at' => strtotime((string) $item->pubDate),
|
||||
]);
|
||||
|
||||
$episodeModel = new EpisodeModel();
|
||||
|
||||
if (! ($newEpisodeId = $episodeModel->insert($newEpisode, true))) {
|
||||
// FIXME: What shall we do?
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', $episodeModel->errors());
|
||||
}
|
||||
|
||||
foreach ($nsPodcast->person as $episodePerson) {
|
||||
$fullName = (string) $episodePerson;
|
||||
$personModel = new PersonModel();
|
||||
$newPersonId = null;
|
||||
if (($newPerson = $personModel->getPerson($fullName)) instanceof Person) {
|
||||
$newPersonId = $newPerson->id;
|
||||
} else {
|
||||
$newPerson = new Person([
|
||||
'full_name' => $fullName,
|
||||
'unique_name' => slugify($fullName),
|
||||
'information_url' => $episodePerson->attributes()['href'],
|
||||
'avatar' => download_file((string) $episodePerson->attributes()['img']),
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
]);
|
||||
|
||||
if (! ($newPersonId = $personModel->insert($newPerson))) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', $personModel->errors());
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: these checks should be in the taxonomy as default values
|
||||
$episodePersonGroup = $episodePerson->attributes()['group'] ?? 'Cast';
|
||||
$episodePersonRole = $episodePerson->attributes()['role'] ?? 'Host';
|
||||
|
||||
$personGroup = ReversedTaxonomy::$taxonomy[(string) $episodePersonGroup];
|
||||
|
||||
$personGroupSlug = $personGroup['slug'];
|
||||
$personRoleSlug = $personGroup['roles'][(string) $episodePersonRole]['slug'];
|
||||
|
||||
$episodePersonModel = new PersonModel();
|
||||
if (! $episodePersonModel->addEpisodePerson(
|
||||
$this->podcast->id,
|
||||
$newEpisodeId,
|
||||
$newPersonId,
|
||||
$personGroupSlug,
|
||||
$personRoleSlug
|
||||
)) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', $episodePersonModel->errors());
|
||||
}
|
||||
}
|
||||
|
||||
$db->transComplete();
|
||||
}
|
||||
|
||||
return redirect()->route('podcast-view', [$this->podcast->id])->with(
|
||||
'message',
|
||||
lang('Podcast.messages.podcastFeedUpdateSuccess', [
|
||||
'number_of_new_episodes' => $lastItem,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@ use App\Models\PersonModel;
|
|||
use App\Models\PodcastModel;
|
||||
use App\Models\PostModel;
|
||||
use CodeIgniter\Files\File;
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
use CodeIgniter\HTTP\RedirectResponse;
|
||||
use Modules\Media\Entities\Audio;
|
||||
use Modules\Media\FileManagers\FileManagerInterface;
|
||||
|
@ -56,7 +57,7 @@ class SettingsController extends BaseController
|
|||
}
|
||||
|
||||
$siteIconFile = $this->request->getFile('site_icon');
|
||||
if ($siteIconFile !== null && $siteIconFile->isValid()) {
|
||||
if ($siteIconFile instanceof UploadedFile && $siteIconFile->isValid()) {
|
||||
/** @var FileManagerInterface $fileManager */
|
||||
$fileManager = service('file_manager');
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@ return [
|
|||
'users' => 'users',
|
||||
'my-account' => 'my account',
|
||||
'change-password' => 'change password',
|
||||
'import' => 'feed import',
|
||||
'imports' => 'imports',
|
||||
'platforms' => 'platforms',
|
||||
'social' => 'social networks',
|
||||
'funding' => 'funding',
|
||||
|
|
|
@ -17,7 +17,8 @@ return [
|
|||
'podcasts' => 'Podcasts',
|
||||
'podcast-list' => 'All podcasts',
|
||||
'podcast-create' => 'New podcast',
|
||||
'podcast-import' => 'Import a podcast',
|
||||
'all-podcast-imports' => 'All Podcast imports',
|
||||
'podcast-imports-add' => 'Import a podcast',
|
||||
'persons' => 'Persons',
|
||||
'person-list' => 'All persons',
|
||||
'person-create' => 'New person',
|
||||
|
|
|
@ -13,6 +13,7 @@ return [
|
|||
'no_podcast' => 'No podcast found!',
|
||||
'create' => 'Create podcast',
|
||||
'import' => 'Import podcast',
|
||||
'all_imports' => 'Podcast imports',
|
||||
'new_episode' => 'New Episode',
|
||||
'view' => 'View podcast',
|
||||
'edit' => 'Edit podcast',
|
||||
|
@ -25,6 +26,8 @@ return [
|
|||
'latest_episodes' => 'Latest episodes',
|
||||
'see_all_episodes' => 'See all episodes',
|
||||
'draft' => 'Draft',
|
||||
'sync_feed' => 'Synchronize feed',
|
||||
'sync_feed_hint' => 'Import this podcast\'s latest episodes',
|
||||
'messages' => [
|
||||
'createSuccess' => 'Podcast successfully created!',
|
||||
'editSuccess' => 'Podcast has been successfully updated!',
|
||||
|
@ -48,7 +51,6 @@ return [
|
|||
other {# episodes were}
|
||||
} added to the podcast!',
|
||||
'podcastFeedUpToDate' => 'Podcast is already up to date.',
|
||||
'podcastNotImported' => 'Podcast could not be updated as it was not imported.',
|
||||
'publishError' => 'This podcast is either already published or scheduled for publication.',
|
||||
'publishEditError' => 'This podcast is not scheduled for publication.',
|
||||
'publishCancelSuccess' => 'Podcast publication successfully cancelled!',
|
||||
|
@ -125,8 +127,6 @@ return [
|
|||
'new_feed_url' => 'New feed URL',
|
||||
'new_feed_url_hint' => 'Use this field when you move to another domain or podcast hosting platform. By default, the value is set to the current RSS URL if the podcast is imported.',
|
||||
'old_feed_url' => 'Old feed URL',
|
||||
'update_feed' => 'Update feed',
|
||||
'update_feed_tip' => 'Import this podcast\'s latest episodes',
|
||||
'partnership' => 'Partnership',
|
||||
'partner_id' => 'ID',
|
||||
'partner_link_url' => 'Link URL',
|
||||
|
|
|
@ -14,6 +14,7 @@ return [
|
|||
'podcast-view' => 'Home',
|
||||
'podcast-edit' => 'Edit podcast',
|
||||
'podcast-persons-manage' => 'Manage persons',
|
||||
'podcast-imports' => 'Podcast imports',
|
||||
'episodes' => 'Episodes',
|
||||
'episode-list' => 'All episodes',
|
||||
'episode-create' => 'New episode',
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright 2020 Ad Aures
|
||||
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||
* @link https://castopod.org/
|
||||
*/
|
||||
|
||||
return [
|
||||
'warning' =>
|
||||
'This procedure may take a long time. As the current version does not show any progress while it runs, you will not see anything updated until it is done. In case of timeout error, increase `max_execution_time` value.',
|
||||
'old_podcast_section_title' => 'The podcast to import',
|
||||
'old_podcast_section_subtitle' =>
|
||||
'Make sure you own the rights for this podcast before importing it. Copying and broadcasting a podcast without the proper rights is piracy and is liable to prosecution.',
|
||||
'imported_feed_url' => 'Feed URL',
|
||||
'imported_feed_url_hint' => 'The feed must be in xml or rss format.',
|
||||
'new_podcast_section_title' => 'The new podcast',
|
||||
'advanced_params_section_title' => 'Advanced parameters',
|
||||
'advanced_params_section_subtitle' =>
|
||||
'Keep the default values if you have no idea of what the fields are for.',
|
||||
'slug_field' => 'Field to be used to calculate episode slug',
|
||||
'description_field' =>
|
||||
'Source field used for episode description / show notes',
|
||||
'force_renumber' => 'Force episodes renumbering',
|
||||
'force_renumber_hint' =>
|
||||
'Use this if your podcast does not have episode numbers but wish to set them during import.',
|
||||
'season_number' => 'Season number',
|
||||
'season_number_hint' =>
|
||||
'Use this if your podcast does not have a season number but wish to set one during import. Leave blank otherwise.',
|
||||
'max_episodes' => 'Maximum number of episodes to import',
|
||||
'max_episodes_hint' => 'Leave blank to import all episodes',
|
||||
'lock_import' =>
|
||||
'This feed is protected. You cannot import it. If you are the owner, unprotect it on the origin platform.',
|
||||
'submit' => 'Import podcast',
|
||||
];
|
|
@ -35,13 +35,13 @@ class EpisodeController extends Controller
|
|||
if ($query !== null) {
|
||||
$builder->fullTextSearch($query);
|
||||
|
||||
if ($order === 'query') {
|
||||
if ($order === 'search') {
|
||||
$builder->orderBy('(episodes_score + podcasts_score)', 'desc');
|
||||
}
|
||||
}
|
||||
|
||||
if ($order === 'newest') {
|
||||
$builder->orderBy($builder->db->getPrefix() . $builder->getTable() . '.created_at', 'desc');
|
||||
$builder->orderBy('episodes.created_at', 'desc');
|
||||
}
|
||||
|
||||
$data = $builder->findAll(
|
||||
|
|
|
@ -2,27 +2,25 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright 2021 Ad Aures
|
||||
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||
* @link https://castopod.org/
|
||||
*/
|
||||
namespace Modules\Fediverse\Commands;
|
||||
|
||||
namespace Modules\Fediverse\Controllers;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use Modules\Fediverse\Models\ActivityModel;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
|
||||
class SchedulerController extends Controller
|
||||
class Broadcast extends BaseCommand
|
||||
{
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
protected $helpers = ['fediverse'];
|
||||
protected $group = 'fediverse';
|
||||
|
||||
public function activity(): void
|
||||
protected $name = 'fediverse:broadcast';
|
||||
|
||||
protected $description = 'Broadcasts new outgoing activity to followers.';
|
||||
|
||||
public function run(array $params): void
|
||||
{
|
||||
helper('fediverse');
|
||||
|
||||
// retrieve scheduled activities from database
|
||||
$scheduledActivities = model('ActivityModel', false)
|
||||
$scheduledActivities = model(ActivityModel::class, false)
|
||||
->getScheduledActivities();
|
||||
|
||||
// Send activity to all followers
|
||||
|
@ -45,7 +43,7 @@ class SchedulerController extends Controller
|
|||
}
|
||||
|
||||
// set activity post to delivered
|
||||
model('ActivityModel', false)
|
||||
model(ActivityModel::class, false)
|
||||
->update($scheduledActivity->id, [
|
||||
'status' => 'delivered',
|
||||
]);
|
|
@ -41,14 +41,13 @@ class Audio extends BaseMedia
|
|||
$getID3 = new GetID3();
|
||||
$audioMetadata = $getID3->analyze($file->getRealPath());
|
||||
|
||||
// remove heavy image data from metadata
|
||||
unset($audioMetadata['comments']['picture']);
|
||||
unset($audioMetadata['id3v2']['APIC']);
|
||||
|
||||
$this->attributes['file_mimetype'] = $audioMetadata['mime_type'];
|
||||
$this->attributes['file_size'] = $audioMetadata['filesize'];
|
||||
$this->attributes['description'] = @$audioMetadata['id3v2']['comments']['comment'][0];
|
||||
$this->attributes['file_metadata'] = json_encode($audioMetadata, JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
$this->attributes['file_metadata'] = json_encode([
|
||||
'playtime_seconds' => $audioMetadata['playtime_seconds'],
|
||||
'avdataoffset' => $audioMetadata['avdataoffset'],
|
||||
], JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
|
|
@ -2,37 +2,37 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright 2021 Ad Aures
|
||||
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||
* @link https://castopod.org/
|
||||
*/
|
||||
|
||||
namespace Modules\Admin\Controllers;
|
||||
namespace Modules\MediaClipper\Commands;
|
||||
|
||||
use App\Models\ClipModel;
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\Files\File;
|
||||
use CodeIgniter\I18n\Time;
|
||||
use Exception;
|
||||
use MediaClipper\VideoClipper;
|
||||
use Modules\MediaClipper\VideoClipper;
|
||||
|
||||
class SchedulerController extends Controller
|
||||
class Generate extends BaseCommand
|
||||
{
|
||||
public function generateVideoClips(): bool
|
||||
protected $group = 'media-clipper';
|
||||
|
||||
protected $name = 'video-clips:generate';
|
||||
|
||||
protected $description = 'Displays basic application information.';
|
||||
|
||||
public function run(array $params): void
|
||||
{
|
||||
// get number of running clips to prevent from having too much running in parallel
|
||||
// TODO: get the number of running ffmpeg processes directly from the machine?
|
||||
$runningVideoClips = (new ClipModel())->getRunningVideoClipsCount();
|
||||
if ($runningVideoClips >= config('Admin')->videoClipWorkers) {
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
// get all clips that haven't been processed yet
|
||||
$scheduledClips = (new ClipModel())->getScheduledVideoClips();
|
||||
|
||||
if ($scheduledClips === []) {
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
|
@ -91,7 +91,5 @@ class SchedulerController extends Controller
|
|||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -2,19 +2,19 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MediaClipper\Config;
|
||||
namespace Modules\MediaClipper\Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
class MediaClipper extends BaseConfig
|
||||
{
|
||||
public string $fontsFolder = APPPATH . 'Libraries/MediaClipper/fonts/';
|
||||
public string $fontsFolder = ROOTPATH . 'modules/MediaClipper/Resources/fonts/';
|
||||
|
||||
public string $quotesImage = APPPATH . 'Libraries/MediaClipper/quotes.png';
|
||||
public string $quotesImage = ROOTPATH . 'modules/MediaClipper/Resources/quotes.png';
|
||||
|
||||
public string $wavesMask = APPPATH . 'Libraries/MediaClipper/waves-mask.png';
|
||||
public string $wavesMask = ROOTPATH . 'modules/MediaClipper/Resources/waves-mask.png';
|
||||
|
||||
public string $watermark = APPPATH . 'Libraries/MediaClipper/castopod-logo.png';
|
||||
public string $watermark = ROOTPATH . 'modules/MediaClipper/Resources/castopod-logo.png';
|
||||
|
||||
/**
|
||||
* @var array<string, array<string, int|array<string, float|int|string>>>
|
||||
|
@ -78,7 +78,7 @@ class MediaClipper extends BaseConfig
|
|||
'rescaleHeight' => 540,
|
||||
'x' => 0,
|
||||
'y' => 810,
|
||||
'mask' => APPPATH . 'Libraries/MediaClipper/soundwaves-mask-landscape.png',
|
||||
'mask' => ROOTPATH . 'modules/MediaClipper/Resources/soundwaves-mask-landscape.png',
|
||||
],
|
||||
'subtitles' => [
|
||||
'fontsize' => 18,
|
||||
|
@ -145,7 +145,7 @@ class MediaClipper extends BaseConfig
|
|||
'rescaleHeight' => 1920,
|
||||
'x' => 0,
|
||||
'y' => 960,
|
||||
'mask' => APPPATH . 'Libraries/MediaClipper/soundwaves-mask-portrait.png',
|
||||
'mask' => ROOTPATH . 'modules/MediaClipper/Resources/soundwaves-mask-portrait.png',
|
||||
],
|
||||
'subtitles' => [
|
||||
'fontsize' => 16,
|
||||
|
@ -213,7 +213,7 @@ class MediaClipper extends BaseConfig
|
|||
'rescaleHeight' => 1200,
|
||||
'x' => 0,
|
||||
'y' => 600,
|
||||
'mask' => APPPATH . 'Libraries/MediaClipper/soundwaves-mask-squared.png',
|
||||
'mask' => ROOTPATH . 'modules/MediaClipper/Resources/soundwaves-mask-squared.png',
|
||||
],
|
||||
'subtitles' => [
|
||||
'fontsize' => 20,
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
@ -8,7 +8,7 @@ declare(strict_types=1);
|
|||
* @link https://castopod.org/
|
||||
*/
|
||||
|
||||
namespace MediaClipper;
|
||||
namespace Modules\MediaClipper;
|
||||
|
||||
use App\Entities\Episode;
|
||||
use Exception;
|
|
@ -0,0 +1,541 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\PodcastImport\Commands;
|
||||
|
||||
use AdAures\PodcastPersonsTaxonomy\ReversedTaxonomy;
|
||||
use App\Entities\Episode;
|
||||
use App\Entities\Location;
|
||||
use App\Entities\Person;
|
||||
use App\Entities\Platform;
|
||||
use App\Entities\Podcast;
|
||||
use App\Models\EpisodeModel;
|
||||
use App\Models\PersonModel;
|
||||
use App\Models\PlatformModel;
|
||||
use App\Models\PodcastModel;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use CodeIgniter\I18n\Time;
|
||||
use CodeIgniter\Shield\Entities\User;
|
||||
use Exception;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
use Modules\Auth\Models\UserModel;
|
||||
use Modules\PodcastImport\Entities\PodcastImportTask;
|
||||
use Modules\PodcastImport\Entities\TaskStatus;
|
||||
use PodcastFeed\PodcastFeed;
|
||||
use PodcastFeed\Tags\Podcast\PodcastPerson;
|
||||
use PodcastFeed\Tags\RSS\Channel;
|
||||
use PodcastFeed\Tags\RSS\Item;
|
||||
|
||||
class PodcastImport extends BaseCommand
|
||||
{
|
||||
protected $group = 'podcast-import';
|
||||
|
||||
protected $name = 'podcast:import';
|
||||
|
||||
protected $description = 'Runs next podcast import in queue.';
|
||||
|
||||
protected PodcastFeed $podcastFeed;
|
||||
|
||||
protected User $user;
|
||||
|
||||
protected ?PodcastImportTask $importTask = null;
|
||||
|
||||
protected ?Podcast $podcast = null;
|
||||
|
||||
public function init(): void
|
||||
{
|
||||
CLI::clearScreen();
|
||||
|
||||
helper('podcast_import');
|
||||
|
||||
$importQueue = get_import_tasks();
|
||||
|
||||
$currentImport = current(array_filter($importQueue, static function ($task): bool {
|
||||
return $task->status === TaskStatus::Running;
|
||||
}));
|
||||
|
||||
if ($currentImport instanceof PodcastImportTask) {
|
||||
$currentImport->syncWithProcess();
|
||||
|
||||
if ($currentImport->status === TaskStatus::Running) {
|
||||
// process is still running
|
||||
throw new Exception('An import is already running.');
|
||||
}
|
||||
|
||||
// continue if the task is not running anymore
|
||||
}
|
||||
|
||||
// Get the next queued import
|
||||
$queuedImports = array_filter($importQueue, static function ($task): bool {
|
||||
return $task->status === TaskStatus::Queued;
|
||||
});
|
||||
$nextImport = end($queuedImports);
|
||||
|
||||
if (! $nextImport instanceof PodcastImportTask) {
|
||||
throw new Exception('No import in queue.');
|
||||
}
|
||||
|
||||
$this->importTask = $nextImport;
|
||||
|
||||
// retrieve user who created import task
|
||||
$user = (new UserModel())->find($this->importTask->created_by);
|
||||
|
||||
if (! $user instanceof User) {
|
||||
throw new Exception('Could not retrieve user with ID: ' . $this->importTask->created_by);
|
||||
}
|
||||
|
||||
$this->user = $user;
|
||||
|
||||
CLI::write('Fetching and parsing RSS feed...');
|
||||
|
||||
ini_set('user_agent', 'Castopod/' . CP_VERSION);
|
||||
$this->podcastFeed = new PodcastFeed($this->importTask->feed_url);
|
||||
}
|
||||
|
||||
public function run(array $params): void
|
||||
{
|
||||
try {
|
||||
$this->init();
|
||||
|
||||
CLI::write('All good! Feed was parsed successfully!');
|
||||
|
||||
CLI::write(
|
||||
'Starting import for @' . $this->importTask->handle . ' using feed: ' . $this->importTask->feed_url
|
||||
);
|
||||
|
||||
// --- START IMPORT TASK ---
|
||||
$this->importTask->start();
|
||||
|
||||
CLI::write('Checking if podcast is locked.');
|
||||
|
||||
if ($this->podcastFeed->channel->podcast_locked->getValue()) {
|
||||
throw new Exception('🔒 Podcast is locked.');
|
||||
}
|
||||
|
||||
CLI::write('Podcast is not locked, import can resume.');
|
||||
|
||||
// check if podcast to be imported already exists by guid if exists or handle otherwise
|
||||
$podcastGuid = $this->podcastFeed->channel->podcast_guid->getValue();
|
||||
if ($podcastGuid !== null) {
|
||||
$podcast = (new PodcastModel())->where('guid', $podcastGuid)
|
||||
->first();
|
||||
} else {
|
||||
$podcast = (new PodcastModel())->where('handle', $this->importTask->handle)
|
||||
->first();
|
||||
}
|
||||
|
||||
if ($podcast instanceof Podcast) {
|
||||
if ($podcast->handle !== $this->importTask->handle) {
|
||||
throw new Exception('Podcast was already imported with a different handle.');
|
||||
}
|
||||
|
||||
CLI::write('Podcast handle already exists, using existing one.');
|
||||
$this->podcast = $podcast;
|
||||
}
|
||||
|
||||
helper(['media', 'misc', 'auth']);
|
||||
|
||||
if (! $this->podcast instanceof Podcast) {
|
||||
$this->podcast = $this->importPodcast();
|
||||
}
|
||||
|
||||
CLI::write('Adding podcast platforms...');
|
||||
|
||||
$this->importPodcastPlatforms();
|
||||
|
||||
CLI::write('Adding persons - ' . count($this->podcastFeed->channel->podcast_persons) . ' elements.');
|
||||
|
||||
$this->importPodcastPersons();
|
||||
|
||||
$this->importEpisodes();
|
||||
|
||||
// set podcast publication date to the first ever published episode
|
||||
$this->podcast->published_at = $this->getOldestEpisodePublicationDate(
|
||||
$this->podcast->id
|
||||
) ?? $this->podcast->created_at;
|
||||
|
||||
$podcastModel = new PodcastModel();
|
||||
if (! $podcastModel->update($this->podcast->id, $this->podcast)) {
|
||||
throw new Exception(print_r($podcastModel->errors()));
|
||||
}
|
||||
|
||||
CLI::showProgress(false);
|
||||
|
||||
// // done, set status to passed
|
||||
$this->importTask->pass();
|
||||
} catch (Exception $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getOldestEpisodePublicationDate(int $podcastId): ?Time
|
||||
{
|
||||
$result = (new EpisodeModel())
|
||||
->builder()
|
||||
->selectMax('published_at', 'oldest_published_at')
|
||||
->where('podcast_id', $podcastId)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
if ($result[0]['oldest_published_at'] === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Time::createFromFormat('Y-m-d H:i:s', $result[0]['oldest_published_at']);
|
||||
}
|
||||
|
||||
private function importPodcast(): Podcast
|
||||
{
|
||||
$db = db_connect();
|
||||
$db->transStart();
|
||||
|
||||
$location = null;
|
||||
if ($this->podcastFeed->channel->podcast_location->getValue() !== null) {
|
||||
$location = new Location(
|
||||
$this->podcastFeed->channel->podcast_location->getValue(),
|
||||
$this->podcastFeed->channel->podcast_location->getAttribute('geo'),
|
||||
$this->podcastFeed->channel->podcast_location->getAttribute('osm'),
|
||||
);
|
||||
}
|
||||
|
||||
if (($showNotes = $this->getShowNotes($this->podcastFeed->channel)) === null) {
|
||||
throw new Exception('Missing channel show notes. Please include a <description> tag.');
|
||||
}
|
||||
|
||||
if (($coverUrl = $this->getCoverUrl($this->podcastFeed->channel)) === null) {
|
||||
throw new Exception('Missing podcast cover. Please include an <itunes:image> tag');
|
||||
}
|
||||
|
||||
$htmlConverter = new HtmlConverter();
|
||||
$podcast = new Podcast([
|
||||
'created_by' => $this->user->id,
|
||||
'updated_by' => $this->user->id,
|
||||
'guid' => $this->podcastFeed->channel->podcast_guid->getValue(),
|
||||
'handle' => $this->importTask->handle,
|
||||
'imported_feed_url' => $this->importTask->feed_url,
|
||||
'new_feed_url' => url_to('podcast-rss-feed', $this->importTask->handle),
|
||||
'title' => $this->podcastFeed->channel->title->getValue(),
|
||||
'description_markdown' => $htmlConverter->convert($showNotes),
|
||||
'description_html' => $showNotes,
|
||||
'cover' => download_file($coverUrl),
|
||||
'banner' => null,
|
||||
'language_code' => $this->importTask->language,
|
||||
'category_id' => $this->importTask->category,
|
||||
'parental_advisory' => $this->podcastFeed->channel->itunes_explicit->getValue(),
|
||||
'owner_name' => $this->podcastFeed->channel->itunes_owner->itunes_name->getValue(),
|
||||
'owner_email' => $this->podcastFeed->channel->itunes_owner->itunes_email->getValue(),
|
||||
'publisher' => $this->podcastFeed->channel->itunes_author->getValue(),
|
||||
'type' => $this->podcastFeed->channel->itunes_type->getValue(),
|
||||
'copyright' => $this->podcastFeed->channel->copyright->getValue(),
|
||||
'is_blocked' => $this->podcastFeed->channel->itunes_block->getValue(),
|
||||
'is_completed' => $this->podcastFeed->channel->itunes_complete->getValue(),
|
||||
'location' => $location,
|
||||
]);
|
||||
|
||||
$podcastModel = new PodcastModel();
|
||||
if (! ($podcastId = $podcastModel->insert($podcast, true))) {
|
||||
$db->transRollback();
|
||||
throw new Exception(print_r($podcastModel->errors()));
|
||||
}
|
||||
|
||||
$podcast->id = $podcastId;
|
||||
|
||||
// set current user as podcast admin
|
||||
// 1. create new group
|
||||
config('AuthGroups')
|
||||
->generatePodcastAuthorizations($podcast->id);
|
||||
add_podcast_group($this->user, $podcast->id, 'admin');
|
||||
|
||||
$db->transComplete(); // save podcast to database
|
||||
|
||||
CLI::write('Podcast was successfully created!');
|
||||
|
||||
return $podcast;
|
||||
}
|
||||
|
||||
private function getShowNotes(Channel|Item $channelOrItem): ?string
|
||||
{
|
||||
if (! $channelOrItem instanceof Item) {
|
||||
return $channelOrItem->description->getValue() ?? $channelOrItem->itunes_summary->getValue();
|
||||
}
|
||||
|
||||
if ($channelOrItem->content_encoded->getValue() !== null) {
|
||||
return $channelOrItem->content_encoded->getValue();
|
||||
}
|
||||
|
||||
return $channelOrItem->description->getValue() ?? $channelOrItem->itunes_summary->getValue();
|
||||
}
|
||||
|
||||
private function getCoverUrl(Channel|Item $channelOrItem): ?string
|
||||
{
|
||||
if ($channelOrItem->itunes_image->getAttribute('href') !== null) {
|
||||
return $channelOrItem->itunes_image->getAttribute('href');
|
||||
}
|
||||
|
||||
if ($channelOrItem instanceof Channel && $channelOrItem->image->url->getValue() !== null) {
|
||||
return $channelOrItem->image->url->getValue();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function importPodcastPersons(): void
|
||||
{
|
||||
$personsCount = count($this->podcastFeed->channel->podcast_persons);
|
||||
$currPersonsStep = 1; // for progress
|
||||
foreach ($this->podcastFeed->channel->podcast_persons as $person) {
|
||||
CLI::showProgress($currPersonsStep++, $personsCount);
|
||||
$fullName = $person->getValue();
|
||||
$newPersonId = null;
|
||||
$personModel = new PersonModel();
|
||||
if (($newPerson = $personModel->getPerson($fullName)) instanceof Person) {
|
||||
$newPersonId = $newPerson->id;
|
||||
} else {
|
||||
$newPodcastPerson = new Person([
|
||||
'created_by' => $this->user->id,
|
||||
'updated_by' => $this->user->id,
|
||||
'full_name' => $fullName,
|
||||
'unique_name' => slugify($fullName),
|
||||
'information_url' => $person->getAttribute('href'),
|
||||
'avatar' => download_file((string) $person->getAttribute('img')),
|
||||
]);
|
||||
|
||||
if (! $newPersonId = $personModel->insert($newPodcastPerson)) {
|
||||
throw new Exception((string) print_r($personModel->errors()));
|
||||
}
|
||||
}
|
||||
|
||||
$personGroup = $person->getAttribute('group');
|
||||
$personRole = $person->getAttribute('role');
|
||||
|
||||
$personGroup = ReversedTaxonomy::$taxonomy[(string) $personGroup];
|
||||
|
||||
$personGroupSlug = $personGroup['slug'];
|
||||
$personRoleSlug = $personGroup['roles'][(string) $personRole]['slug'];
|
||||
|
||||
$podcastPersonModel = new PersonModel();
|
||||
if (! $podcastPersonModel->addPodcastPerson(
|
||||
$this->podcast->id,
|
||||
$newPersonId,
|
||||
$personGroupSlug,
|
||||
$personRoleSlug
|
||||
)) {
|
||||
throw new Exception(print_r($podcastPersonModel->errors()));
|
||||
}
|
||||
}
|
||||
|
||||
CLI::showProgress(false);
|
||||
}
|
||||
|
||||
private function importPodcastPlatforms(): void
|
||||
{
|
||||
$platformTypes = [
|
||||
[
|
||||
'name' => 'podcasting',
|
||||
'elements' => $this->podcastFeed->channel->podcast_ids,
|
||||
'count' => count($this->podcastFeed->channel->podcast_ids),
|
||||
'account_url_key' => 'url',
|
||||
'account_id_key' => 'id',
|
||||
],
|
||||
[
|
||||
'name' => 'social',
|
||||
'elements' => $this->podcastFeed->channel->podcast_socials,
|
||||
'count' => count($this->podcastFeed->channel->podcast_socials),
|
||||
'account_url_key' => 'accountUrl',
|
||||
'account_id_key' => 'accountId',
|
||||
],
|
||||
[
|
||||
'name' => 'funding',
|
||||
'elements' => $this->podcastFeed->channel->podcast_fundings,
|
||||
'count' => count($this->podcastFeed->channel->podcast_fundings),
|
||||
'account_url_key' => 'url',
|
||||
'account_id_key' => 'id',
|
||||
],
|
||||
];
|
||||
|
||||
$platformModel = new PlatformModel();
|
||||
foreach ($platformTypes as $platformType) {
|
||||
$podcastsPlatformsData = [];
|
||||
$currPlatformStep = 1; // for progress
|
||||
CLI::write($platformType['name'] . ' - ' . $platformType['count'] . ' elements');
|
||||
foreach ($platformType['elements'] as $platform) {
|
||||
CLI::showProgress($currPlatformStep++, $platformType['count']);
|
||||
$platformLabel = $platform->getAttribute('platform');
|
||||
$platformSlug = slugify((string) $platformLabel);
|
||||
if ($platformModel->getPlatform($platformSlug) instanceof Platform) {
|
||||
$podcastsPlatformsData[] = [
|
||||
'platform_slug' => $platformSlug,
|
||||
'podcast_id' => $this->podcast->id,
|
||||
'link_url' => $platform->getAttribute($platformType['account_url_key']),
|
||||
'account_id' => $platform->getAttribute($platformType['account_id_key']),
|
||||
'is_visible' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$platformModel->savePodcastPlatforms($this->podcast->id, $platformType['name'], $podcastsPlatformsData);
|
||||
CLI::showProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
private function importEpisodes(): void
|
||||
{
|
||||
helper('text');
|
||||
|
||||
$itemsCount = count($this->podcastFeed->channel->items);
|
||||
$this->importTask->setEpisodesCount($itemsCount);
|
||||
|
||||
CLI::write('Adding episodes - ' . $itemsCount . ' episodes');
|
||||
|
||||
$htmlConverter = new HtmlConverter();
|
||||
|
||||
$importedGUIDs = $this->getImportedGUIDs($this->podcast->id);
|
||||
|
||||
$currEpisodesStep = 0; // for progress
|
||||
$episodesNewlyImported = 0;
|
||||
$episodesAlreadyImported = 0;
|
||||
|
||||
// insert episodes in reverse order, from the last item in the list to the first
|
||||
foreach (array_reverse($this->podcastFeed->channel->items) as $key => $item) {
|
||||
CLI::showProgress(++$currEpisodesStep, $itemsCount);
|
||||
|
||||
if (in_array($item->guid->getValue(), $importedGUIDs, true)) {
|
||||
// do not import item if already imported
|
||||
// (check that item with guid has already been inserted)
|
||||
$this->importTask->setEpisodesAlreadyImported(++$episodesAlreadyImported);
|
||||
continue;
|
||||
}
|
||||
|
||||
$db = db_connect();
|
||||
$db->transStart();
|
||||
|
||||
$location = null;
|
||||
if ($item->podcast_location->getValue() !== null) {
|
||||
$location = new Location(
|
||||
$item->podcast_location->getValue(),
|
||||
$item->podcast_location->getAttribute('geo'),
|
||||
$item->podcast_location->getAttribute('osm'),
|
||||
);
|
||||
}
|
||||
|
||||
if (($showNotes = $this->getShowNotes($item)) === null) {
|
||||
throw new Exception('Missing item show notes. Please include a <description> tag to item ' . $key);
|
||||
}
|
||||
|
||||
$coverUrl = $this->getCoverUrl($item);
|
||||
|
||||
$episode = new Episode([
|
||||
'created_by' => $this->user->id,
|
||||
'updated_by' => $this->user->id,
|
||||
'podcast_id' => $this->podcast->id,
|
||||
'title' => $item->title->getValue(),
|
||||
'slug' => slugify((string) $item->title->getValue(), 120) . '-' . strtolower(
|
||||
random_string('alnum', 5)
|
||||
),
|
||||
'guid' => $item->guid->getValue(),
|
||||
'audio' => download_file(
|
||||
$item->enclosure->getAttribute('url'),
|
||||
$item->enclosure->getAttribute('type')
|
||||
),
|
||||
'description_markdown' => $htmlConverter->convert($showNotes),
|
||||
'description_html' => $showNotes,
|
||||
'cover' => $coverUrl ? download_file($coverUrl) : null,
|
||||
'parental_advisory' => $item->itunes_explicit->getValue(),
|
||||
'number' => $item->itunes_episode->getValue(),
|
||||
'season_number' => $item->itunes_season->getValue(),
|
||||
'type' => $item->itunes_episodeType->getValue(),
|
||||
'is_blocked' => $item->itunes_block->getValue(),
|
||||
'location' => $location,
|
||||
'published_at' => $item->pubDate->getValue(),
|
||||
]);
|
||||
|
||||
$episodeModel = new EpisodeModel();
|
||||
|
||||
if (! ($episodeId = $episodeModel->insert($episode, true))) {
|
||||
$db->transRollback();
|
||||
throw new Exception(print_r($episodeModel->errors()));
|
||||
}
|
||||
|
||||
$this->importEpisodePersons($episodeId, $item->podcast_persons);
|
||||
|
||||
$this->importTask->setEpisodesNewlyImported(++$episodesNewlyImported);
|
||||
|
||||
$db->transComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getImportedGUIDs(int $podcastId): array
|
||||
{
|
||||
$result = (new EpisodeModel())
|
||||
->builder()
|
||||
->select('guid')
|
||||
->where('podcast_id', $podcastId)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
return array_map(static function ($element) {
|
||||
return $element['guid'];
|
||||
}, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PodcastPerson[] $persons
|
||||
*/
|
||||
private function importEpisodePersons(int $episodeId, array $persons): void
|
||||
{
|
||||
foreach ($persons as $person) {
|
||||
$fullName = $person->getValue();
|
||||
$personModel = new PersonModel();
|
||||
$newPersonId = null;
|
||||
if (($newPerson = $personModel->getPerson($fullName)) instanceof Person) {
|
||||
$newPersonId = $newPerson->id;
|
||||
} else {
|
||||
$newPerson = new Person([
|
||||
'created_by' => $this->user->id,
|
||||
'updated_by' => $this->user->id,
|
||||
'full_name' => $fullName,
|
||||
'unique_name' => slugify($fullName),
|
||||
'information_url' => $person->getAttribute('href'),
|
||||
'avatar' => download_file((string) $person->getAttribute('img')),
|
||||
]);
|
||||
|
||||
if (! ($newPersonId = $personModel->insert($newPerson))) {
|
||||
throw new Exception(print_r($personModel->errors()));
|
||||
}
|
||||
}
|
||||
|
||||
$personGroup = $person->getAttribute('group');
|
||||
$personRole = $person->getAttribute('role');
|
||||
|
||||
$personGroup = ReversedTaxonomy::$taxonomy[(string) $personGroup];
|
||||
|
||||
$personGroupSlug = $personGroup['slug'];
|
||||
$personRoleSlug = $personGroup['roles'][(string) $personRole]['slug'];
|
||||
|
||||
$episodePersonModel = new PersonModel();
|
||||
if (! $episodePersonModel->addEpisodePerson(
|
||||
$this->podcast->id,
|
||||
$episodeId,
|
||||
$newPersonId,
|
||||
$personGroupSlug,
|
||||
$personRoleSlug
|
||||
)) {
|
||||
throw new Exception(print_r($episodePersonModel->errors()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function error(string $message): void
|
||||
{
|
||||
if ($this->importTask instanceof PodcastImportTask) {
|
||||
$this->importTask->fail($message);
|
||||
}
|
||||
|
||||
CLI::error('[Error] ' . $message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\PodcastImport\Config;
|
||||
|
||||
use CodeIgniter\Router\RouteCollection;
|
||||
|
||||
/** @var RouteCollection $routes */
|
||||
$routes = service('routes');
|
||||
|
||||
// Admin routes for imports
|
||||
$routes->group(
|
||||
config('Admin')
|
||||
->gateway,
|
||||
[
|
||||
'namespace' => 'Modules\PodcastImport\Controllers',
|
||||
],
|
||||
static function ($routes): void {
|
||||
$routes->get('imports', 'PodcastImportController::list', [
|
||||
'as' => 'all-podcast-imports',
|
||||
'filter' => 'permission:podcasts.import',
|
||||
]);
|
||||
$routes->get('imports/add', 'PodcastImportController::addToQueueView', [
|
||||
'as' => 'podcast-imports-add',
|
||||
'filter' => 'permission:podcasts.import',
|
||||
]);
|
||||
$routes->post('imports/add', 'PodcastImportController::addToQueueAction', [
|
||||
'filter' => 'permission:podcasts.import',
|
||||
]);
|
||||
$routes->get('imports/(:segment)/(:alpha)', 'PodcastImportController::taskAction/$1/$2', [
|
||||
'as' => 'podcast-imports-task-action',
|
||||
'filter' => 'permission:podcasts.import',
|
||||
]);
|
||||
|
||||
$routes->group('podcasts/(:num)', static function ($routes): void {
|
||||
$routes->get('imports', 'PodcastImportController::podcastList/$1', [
|
||||
'as' => 'podcast-imports',
|
||||
'filter' => 'permission:podcast#.manage-import',
|
||||
]);
|
||||
$routes->get('sync-feed', 'PodcastImportController::syncImport/$1', [
|
||||
'as' => 'podcast-imports-sync',
|
||||
'filter' => 'permission:podcast#.manage-import',
|
||||
]);
|
||||
});
|
||||
}
|
||||
);
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright 2023 Ad Aures
|
||||
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||
* @link https://castopod.org/
|
||||
*/
|
||||
|
||||
namespace Modules\PodcastImport\Controllers;
|
||||
|
||||
use App\Entities\Podcast;
|
||||
use App\Models\CategoryModel;
|
||||
use App\Models\LanguageModel;
|
||||
use App\Models\PodcastModel;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
use CodeIgniter\HTTP\RedirectResponse;
|
||||
use CodeIgniter\I18n\Time;
|
||||
use Exception;
|
||||
use Modules\Admin\Controllers\BaseController;
|
||||
use Modules\PodcastImport\Entities\PodcastImportTask;
|
||||
use Modules\PodcastImport\Entities\TaskStatus;
|
||||
|
||||
class PodcastImportController extends BaseController
|
||||
{
|
||||
public function list(): string
|
||||
{
|
||||
helper('podcast_import');
|
||||
|
||||
return view('import/queue', [
|
||||
'podcastImportsQueue' => get_import_tasks(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function podcastList(int $podcastId): string
|
||||
{
|
||||
if (! ($podcast = (new PodcastModel())->getPodcastById($podcastId)) instanceof Podcast) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
helper('podcast_import');
|
||||
|
||||
replace_breadcrumb_params([
|
||||
0 => $podcast->at_handle,
|
||||
]);
|
||||
return view('import/podcast_queue', [
|
||||
'podcast' => $podcast,
|
||||
'podcastImportsQueue' => get_import_tasks($podcast->handle),
|
||||
]);
|
||||
}
|
||||
|
||||
public function addToQueueView(): string
|
||||
{
|
||||
helper(['form', 'misc']);
|
||||
|
||||
$languageOptions = (new LanguageModel())->getLanguageOptions();
|
||||
$categoryOptions = (new CategoryModel())->getCategoryOptions();
|
||||
|
||||
$data = [
|
||||
'languageOptions' => $languageOptions,
|
||||
'categoryOptions' => $categoryOptions,
|
||||
'browserLang' => get_browser_language($this->request->getServer('HTTP_ACCEPT_LANGUAGE')),
|
||||
];
|
||||
|
||||
return view('import/add_to_queue', $data);
|
||||
}
|
||||
|
||||
public function addToQueueAction(): RedirectResponse
|
||||
{
|
||||
$rules = [
|
||||
'handle' => 'required|regex_match[/^[a-zA-Z0-9\_]{1,32}$/]',
|
||||
'imported_feed_url' => 'required|valid_url_strict',
|
||||
'max_episodes' => 'is_natural_no_zero|permit_empty',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
// TODO: check that handle is not already in use
|
||||
|
||||
$importTask = new PodcastImportTask([
|
||||
'handle' => $this->request->getPost('handle'),
|
||||
'feed_url' => $this->request->getPost('imported_feed_url'),
|
||||
'language' => $this->request->getPost('language'),
|
||||
'category' => $this->request->getPost('category'),
|
||||
'status' => TaskStatus::Queued,
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'created_at' => Time::now(),
|
||||
'updated_at' => Time::now(),
|
||||
]);
|
||||
|
||||
$importTask->save();
|
||||
|
||||
return redirect()->route('all-podcast-imports')
|
||||
->with('message', lang('PodcastImport.messages.importTaskQueued'));
|
||||
}
|
||||
|
||||
public function syncImport(int $podcastId): RedirectResponse
|
||||
{
|
||||
if (! ($podcast = (new PodcastModel())->getPodcastById($podcastId)) instanceof Podcast) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
if ($podcast->imported_feed_url === null) {
|
||||
return redirect()
|
||||
->back()
|
||||
->with('error', lang('PodcastImport.messages.podcastNotImported'));
|
||||
}
|
||||
|
||||
// create update task in podcastImport
|
||||
$importTask = new PodcastImportTask([
|
||||
'handle' => $podcast->handle,
|
||||
'feed_url' => $podcast->imported_feed_url,
|
||||
'language' => $podcast->language_code,
|
||||
'category' => $podcast->category_id,
|
||||
'status' => TaskStatus::Queued,
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'created_at' => Time::now(),
|
||||
'updated_at' => Time::now(),
|
||||
]);
|
||||
|
||||
$importTask->save();
|
||||
|
||||
return redirect()->route('podcast-imports', [$podcastId])
|
||||
->with('message', lang('PodcastImport.messages.syncTaskQueued'));
|
||||
}
|
||||
|
||||
public function taskAction(string $taskId, string $action): RedirectResponse
|
||||
{
|
||||
/** @var array<string, PodcastImportTask> $importQueue */
|
||||
$importQueue = service('settings')
|
||||
->get('Import.queue') ?? [];
|
||||
|
||||
if (! array_key_exists($taskId, $importQueue)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
$importTask = $importQueue[$taskId];
|
||||
switch ($action) {
|
||||
case 'cancel':
|
||||
$importTask->cancel();
|
||||
return redirect()->back()
|
||||
->with('message', lang('PodcastImport.messages.canceled'));
|
||||
case 'retry':
|
||||
if ($importTask->status === TaskStatus::Running) {
|
||||
return redirect()->back()
|
||||
->with('error', lang('PodcastImport.messages.alreadyRunning'));
|
||||
}
|
||||
|
||||
$newImportTask = new PodcastImportTask([
|
||||
'handle' => $importTask->handle,
|
||||
'feed_url' => $importTask->feed_url,
|
||||
'language' => $importTask->language,
|
||||
'category' => $importTask->category,
|
||||
'status' => TaskStatus::Queued,
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
'created_at' => Time::now(),
|
||||
'updated_at' => Time::now(),
|
||||
]);
|
||||
|
||||
$newImportTask->save();
|
||||
|
||||
return redirect()->back()
|
||||
->with('message', lang('PodcastImport.messages.retried'));
|
||||
case 'delete':
|
||||
$importTask->delete();
|
||||
return redirect()->back()
|
||||
->with('message', lang('PodcastImport.messages.deleted'));
|
||||
default:
|
||||
throw new Exception('Task action ' . $action . ' was not implemented');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,242 @@
|
|||
<?php
|
||||
|
||||
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 Modules\PodcastImport\Entities;
|
||||
|
||||
use CodeIgniter\Entity\Entity;
|
||||
use CodeIgniter\I18n\Time;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $handle
|
||||
* @property string $feed_url
|
||||
* @property string $language
|
||||
* @property string $category
|
||||
* @property TaskStatus $status
|
||||
* @property ?string $error
|
||||
* @property int $episodes_newly_imported
|
||||
* @property int $episodes_already_imported
|
||||
* @property int $episodes_imported
|
||||
* @property ?int $episodes_count
|
||||
* @property int $progress
|
||||
* @property int $duration
|
||||
*
|
||||
* @property ?int $process_id
|
||||
*
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
*
|
||||
* @property ?Time $started_at
|
||||
* @property ?Time $ended_at
|
||||
* @property Time $created_at
|
||||
* @property Time $updated_at
|
||||
*/
|
||||
class PodcastImportTask extends Entity
|
||||
{
|
||||
public string $error = '';
|
||||
|
||||
public int $episodes_already_imported = 0;
|
||||
|
||||
public int $episodes_newly_imported = 0;
|
||||
|
||||
public ?int $episodes_count = null;
|
||||
|
||||
protected ?int $duration = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function __construct(array $data)
|
||||
{
|
||||
parent::__construct($data);
|
||||
|
||||
if (! array_key_exists('id', $data)) {
|
||||
$this->id = md5($this->feed_url . Time::now());
|
||||
}
|
||||
}
|
||||
|
||||
public function getProgress(): float
|
||||
{
|
||||
if ($this->episodes_count === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->episodes_imported / $this->episodes_count;
|
||||
}
|
||||
|
||||
public function getEpisodesImported(): int
|
||||
{
|
||||
return $this->episodes_newly_imported + $this->episodes_already_imported;
|
||||
}
|
||||
|
||||
public function setEpisodesNewlyImported(int $episodesImported): void
|
||||
{
|
||||
$this->episodes_newly_imported = $episodesImported;
|
||||
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function setEpisodesAlreadyImported(int $episodesImported): void
|
||||
{
|
||||
$this->episodes_already_imported = $episodesImported;
|
||||
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function setEpisodesCount(int $episodesCount): void
|
||||
{
|
||||
$this->episodes_count = $episodesCount;
|
||||
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function getDuration(): int
|
||||
{
|
||||
if ($this->duration === null && $this->started_at && $this->ended_at) {
|
||||
$this->duration = ($this->started_at->difference($this->ended_at))
|
||||
->getSeconds();
|
||||
}
|
||||
|
||||
return $this->duration;
|
||||
}
|
||||
|
||||
public function start(): void
|
||||
{
|
||||
if ($this->process_id !== null) {
|
||||
throw new Exception('Task is already running!');
|
||||
}
|
||||
|
||||
$processId = getmypid();
|
||||
|
||||
if ($processId === false) {
|
||||
throw new Exception('Error Processing Request', 1);
|
||||
}
|
||||
|
||||
$this->process_id = $processId;
|
||||
$this->started_at = Time::now();
|
||||
$this->status = TaskStatus::Running;
|
||||
$this->save();
|
||||
|
||||
service('settings')
|
||||
->set('Import.current', $this->handle);
|
||||
}
|
||||
|
||||
public function pass(): void
|
||||
{
|
||||
$this->process_id = null;
|
||||
$this->ended_at = Time::now();
|
||||
$this->status = TaskStatus::Passed;
|
||||
|
||||
$this->save();
|
||||
|
||||
service('settings')
|
||||
->forget('Import.current');
|
||||
}
|
||||
|
||||
public function cancel(): void
|
||||
{
|
||||
if ($this->status !== TaskStatus::Running && $this->status !== TaskStatus::Queued) {
|
||||
throw new Exception('Task can only be canceled if running or queued.');
|
||||
}
|
||||
|
||||
if ($this->isProcessRunning()) {
|
||||
// kill process
|
||||
$isProcessKilled = posix_kill($this->process_id, 9);
|
||||
|
||||
if (! $isProcessKilled) {
|
||||
throw new Exception('Something wrong happened, process could not be killed.');
|
||||
}
|
||||
}
|
||||
|
||||
$this->process_id = null;
|
||||
$this->status = TaskStatus::Canceled;
|
||||
$this->ended_at = Time::now();
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
if ($this->isProcessRunning()) {
|
||||
$this->cancel();
|
||||
}
|
||||
|
||||
$importQueue = service('settings')
|
||||
->get('Import.queue') ?? [];
|
||||
|
||||
if ($importQueue === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($importQueue[$this->id]);
|
||||
|
||||
service('settings')
|
||||
->set('Import.queue', $importQueue);
|
||||
}
|
||||
|
||||
public function fail(string $message): void
|
||||
{
|
||||
$this->error = $message;
|
||||
|
||||
$this->status = TaskStatus::Failed;
|
||||
$this->ended_at = Time::now();
|
||||
$this->save();
|
||||
|
||||
service('settings')
|
||||
->forget('Import.current');
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$importQueue = service('settings')
|
||||
->get('Import.queue') ?? [];
|
||||
|
||||
$now = Time::now();
|
||||
|
||||
if (! array_key_exists($this->id, $importQueue)) {
|
||||
$this->created_at = $now;
|
||||
}
|
||||
|
||||
$this->updated_at = $now;
|
||||
|
||||
$importQueue[$this->id] = $this;
|
||||
|
||||
service('settings')
|
||||
->set('Import.queue', $importQueue);
|
||||
}
|
||||
|
||||
public function syncWithProcess(): void
|
||||
{
|
||||
if ($this->status !== TaskStatus::Running && $this->process_id !== null) {
|
||||
$this->process_id = null;
|
||||
$this->save();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->status === TaskStatus::Running && $this->process_id === null) {
|
||||
$this->fail('Running task has no process id set.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->isProcessRunning()) {
|
||||
$this->fail('Process was killed.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function isProcessRunning(): bool
|
||||
{
|
||||
if ($this->process_id === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return posix_getpgid($this->process_id) !== false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\PodcastImport\Entities;
|
||||
|
||||
enum TaskStatus: string
|
||||
{
|
||||
case Queued = 'queued';
|
||||
case Running = 'running';
|
||||
case Canceled = 'canceled';
|
||||
case Failed = 'failed';
|
||||
case Passed = 'passed';
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Modules\PodcastImport\Entities\PodcastImportTask;
|
||||
use Modules\PodcastImport\Entities\TaskStatus;
|
||||
|
||||
/**
|
||||
* @copyright 2023 Ad Aures
|
||||
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||
* @link https://castopod.org/
|
||||
*/
|
||||
|
||||
|
||||
if (! function_exists('get_import_tasks')) {
|
||||
/**
|
||||
* @return PodcastImportTask[]
|
||||
*/
|
||||
function get_import_tasks(?string $podcastHandle = null): array
|
||||
{
|
||||
/** @var PodcastImportTask[] $podcastImportsQueue */
|
||||
$podcastImportsQueue = service('settings')
|
||||
->get('Import.queue') ?? [];
|
||||
|
||||
if (! is_array($podcastImportsQueue)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($podcastHandle !== null) {
|
||||
$podcastImportsQueue = array_filter($podcastImportsQueue, static function ($importTask) use (
|
||||
$podcastHandle
|
||||
): bool {
|
||||
return $importTask->handle === $podcastHandle;
|
||||
});
|
||||
}
|
||||
|
||||
usort($podcastImportsQueue, static function (PodcastImportTask $a, PodcastImportTask $b): int {
|
||||
if ($a->status === $b->status) {
|
||||
return $a->created_at->isAfter($b->created_at) ? -1 : 1;
|
||||
}
|
||||
|
||||
if ($a->status === TaskStatus::Running) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if ($a->status === TaskStatus::Queued && $b->status !== TaskStatus::Running) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return $a->created_at->isAfter($b->created_at) ? -1 : 1;
|
||||
});
|
||||
|
||||
return array_values($podcastImportsQueue);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright 2020 Ad Aures
|
||||
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||
* @link https://castopod.org/
|
||||
*/
|
||||
|
||||
return [
|
||||
'banner' => [
|
||||
'disclaimer' => 'Importing',
|
||||
'text' => '{podcastTitle} is currently being imported.',
|
||||
'cta' => 'See import status',
|
||||
],
|
||||
'old_podcast_section_title' => 'The podcast to import',
|
||||
'old_podcast_legal_disclaimer_title' => 'Legal disclaimer',
|
||||
'old_podcast_legal_disclaimer' =>
|
||||
'Make sure you own the rights for this podcast before importing it. Copying and broadcasting a podcast without the proper rights is piracy and is liable to prosecution.',
|
||||
'imported_feed_url' => 'Feed URL',
|
||||
'imported_feed_url_hint' => 'The feed must be in xml or rss format.',
|
||||
'new_podcast_section_title' => 'The new podcast',
|
||||
'lock_import' =>
|
||||
'This feed is protected. You cannot import it. If you are the owner, unlock it on the origin platform.',
|
||||
'submit' => 'Add import to queue',
|
||||
'queue' => [
|
||||
'status' => [
|
||||
'label' => 'Status',
|
||||
'queued' => 'queued',
|
||||
'queued_hint' => 'Import task is awaiting to be processed.',
|
||||
'canceled' => 'canceled',
|
||||
'canceled_hint' => 'Import task was canceled.',
|
||||
'running' => 'running',
|
||||
'running_hint' => 'Import task is being processed.',
|
||||
'failed' => 'failed',
|
||||
'failed_hint' => 'Import task could not complete: script failure.',
|
||||
'passed' => 'passed',
|
||||
'passed_hint' => 'Import task was completed successfully!',
|
||||
],
|
||||
'feed' => 'Feed',
|
||||
'duration' => 'Import duration',
|
||||
'imported_episodes' => 'Imported episodes',
|
||||
'imported_episodes_hint' => '{newlyImportedCount} newly imported, {alreadyImportedCount} already imported.',
|
||||
'actions' => [
|
||||
'cancel' => 'Cancel',
|
||||
'retry' => 'Retry',
|
||||
'delete' => 'Delete',
|
||||
],
|
||||
],
|
||||
'messages' => [
|
||||
'canceled' => 'Import task has been successfully canceled!',
|
||||
'alreadyRunning' => 'Import Task is already running. You may cancel it before retrying.',
|
||||
'retried' => 'Import task has been queued, it will be retried shortly!',
|
||||
'deleted' => 'Import task has been successfully deleted!',
|
||||
'importTaskQueued' => 'An new task has been queued, import will start shortly!',
|
||||
'podcastNotImported' => 'Podcast cannot be synched as it was not imported.',
|
||||
'syncTaskQueued' => 'A new import task has been queued, synchronization will start shortly!',
|
||||
],
|
||||
];
|
|
@ -2,23 +2,23 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright 2022 Ad Aures
|
||||
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||
* @link https://castopod.org/
|
||||
*/
|
||||
|
||||
namespace Modules\WebSub\Controllers;
|
||||
namespace Modules\WebSub\Commands;
|
||||
|
||||
use App\Models\EpisodeModel;
|
||||
use App\Models\PodcastModel;
|
||||
use CodeIgniter\Controller;
|
||||
use Config\Services;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\HTTP\CURLRequest;
|
||||
use Exception;
|
||||
|
||||
class WebSubController extends Controller
|
||||
class Publish extends BaseCommand
|
||||
{
|
||||
public function publish(): void
|
||||
protected $group = 'Websub';
|
||||
|
||||
protected $name = 'websub:publish';
|
||||
|
||||
protected $description = 'Publishes feed updates to websub hubs.';
|
||||
|
||||
public function run(array $params): void
|
||||
{
|
||||
if (ENVIRONMENT !== 'production') {
|
||||
return;
|
||||
|
@ -45,7 +45,8 @@ class WebSubController extends Controller
|
|||
return;
|
||||
}
|
||||
|
||||
$request = Services::curlrequest();
|
||||
/** @var CURLRequest $request */
|
||||
$request = service('curlrequest');
|
||||
|
||||
$requestOptions = [
|
||||
'headers' => [
|
|
@ -1,21 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright 2022 Ad Aures
|
||||
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||
* @link https://castopod.org/
|
||||
*/
|
||||
|
||||
$routes = service('routes');
|
||||
|
||||
/**
|
||||
* WebSub routes file
|
||||
*/
|
||||
|
||||
$routes->group('', [
|
||||
'namespace' => 'Modules\WebSub\Controllers',
|
||||
], static function ($routes): void {
|
||||
$routes->cli('scheduled-websub-publish', 'WebSubController::publish');
|
||||
});
|
12
phpstan.neon
|
@ -7,15 +7,9 @@ parameters:
|
|||
bootstrapFiles:
|
||||
- vendor/codeigniter4/framework/system/Test/bootstrap.php
|
||||
scanDirectories:
|
||||
- app/Helpers
|
||||
- modules/Analytics/Helpers
|
||||
- modules/Auth/Helpers
|
||||
- modules/Fediverse/Helpers
|
||||
- modules/Media/Helpers
|
||||
- modules/PremiumPodcasts/Helpers
|
||||
- vendor/codeigniter4/framework/system/Helpers
|
||||
- vendor/codeigniter4/settings/src/Helpers
|
||||
- vendor/codeigniter4/shield/src/Helpers
|
||||
- app
|
||||
- modules
|
||||
- vendor/codeigniter4
|
||||
excludePaths:
|
||||
- app/Libraries/Router.php
|
||||
- app/Views/*
|
||||
|
|
|
@ -51,9 +51,22 @@ $isEpisodeArea = isset($podcast) && isset($episode);
|
|||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<?php if ($isPodcastArea && $podcast->publication_status !== 'published'): ?>
|
||||
<?php if ($isPodcastArea): ?>
|
||||
<?php if (service('settings')->get('Import.current') === $podcast->handle): ?>
|
||||
<div class="flex items-center px-12 py-2 border-b bg-stripes-warning border-subtle" role="alert">
|
||||
<p class="flex items-center text-gray-900">
|
||||
<span class="inline-flex items-center gap-1 text-xs font-semibold tracking-wide uppercase"><Icon glyph="download" class="text-base text-yellow-900"/><?= lang('PodcastImport.banner.disclaimer') ?></span>
|
||||
<span class="ml-3 text-sm"><?= lang('PodcastImport.banner.text', [
|
||||
'podcastTitle' => $podcast->title,
|
||||
]) ?></span>
|
||||
</p>
|
||||
<a href="<?= route_to('podcast-imports', $podcast->id) ?>" class="ml-1 text-sm font-semibold underline shadow-xs text-accent-base hover:text-accent-hover hover:no-underline"><?= lang('PodcastImport.banner.cta') ?></a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($podcast->publication_status !== 'published'): ?>
|
||||
<?= publication_status_banner($podcast->published_at, $podcast->id, $podcast->publication_status) ?>
|
||||
<?php endif ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<div class="px-2 py-8 mx-auto md:px-12">
|
||||
<?= view('_message_block') ?>
|
||||
<?= $this->renderSection('content') ?>
|
||||
|
|
|
@ -7,7 +7,7 @@ $navigation = [
|
|||
],
|
||||
'podcasts' => [
|
||||
'icon' => 'mic',
|
||||
'items' => ['podcast-list', 'podcast-create', 'podcast-import'],
|
||||
'items' => ['podcast-list', 'podcast-create', 'all-podcast-imports', 'podcast-imports-add'],
|
||||
],
|
||||
'persons' => [
|
||||
'icon' => 'folder-user',
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
</p>
|
||||
<form class="relative flex">
|
||||
<div class="relative">
|
||||
<Forms.Input name="q" placeholder="<?= lang('Episode.list.search.placeholder') ?>" value="<?= $query ?>" class="<?= $query ? 'pr-8' : '' ?>" />
|
||||
<Forms.Input name="q" placeholder="<?= lang('Episode.list.search.placeholder') ?>" value="<?= esc($query) ?>" class="<?= $query ? 'pr-8' : '' ?>" />
|
||||
<?php if ($query): ?>
|
||||
<a href="<?= route_to('episode-list', $podcast->id) ?>" class="absolute inset-y-0 right-0 inline-flex items-center justify-center px-2 opacity-75 focus:ring-accent hover:opacity-100 focus:opacity-100" title="<?= lang('Episode.list.search.clear') ?>" data-tooltip="bottom"><?= icon('close', 'text-lg') ?></a>
|
||||
<?php endif; ?>
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
use CodeIgniter\I18n\Time;
|
||||
use Modules\PodcastImport\Entities\PodcastImportTask;
|
||||
use Modules\PodcastImport\Entities\TaskStatus;
|
||||
|
||||
?>
|
||||
|
||||
<?= data_table(
|
||||
[
|
||||
[
|
||||
'header' => lang('PodcastImport.queue.status.label'),
|
||||
'cell' => function (PodcastImportTask $importTask) {
|
||||
$pillVariantMap = [
|
||||
'queued' => 'default',
|
||||
'pending' => 'warning',
|
||||
'running' => 'primary',
|
||||
'canceled' => 'default',
|
||||
'failed' => 'danger',
|
||||
'passed' => 'success',
|
||||
];
|
||||
|
||||
$pillIconMap = [
|
||||
'queued' => 'timer',
|
||||
'pending' => 'pause',
|
||||
'running' => 'loader',
|
||||
'canceled' => 'forbid',
|
||||
'failed' => 'close',
|
||||
'passed' => 'check',
|
||||
];
|
||||
|
||||
$pillIconClassMap = [
|
||||
'queued' => '',
|
||||
'pending' => '',
|
||||
'running' => 'animate-spin',
|
||||
'canceled' => '',
|
||||
'failed' => '',
|
||||
'passed' => '',
|
||||
];
|
||||
|
||||
$errorHint = $importTask->status === TaskStatus::Failed ? hint_tooltip($importTask->error, 'ml-1') : '';
|
||||
|
||||
return '<div class="flex items-center"><Pill variant="' . $pillVariantMap[$importTask->status->value] . '" icon="' . $pillIconMap[$importTask->status->value] . '" iconClass="' . $pillIconClassMap[$importTask->status->value] . '" hint="' . lang('PodcastImport.queue.status.' . $importTask->status->value . '_hint') . '">' . lang('PodcastImport.queue.status.' . $importTask->status->value) . '</Pill>' . $errorHint . '</div>';
|
||||
},
|
||||
],
|
||||
[
|
||||
'header' => lang('PodcastImport.queue.feed'),
|
||||
'cell' => function (PodcastImportTask $importTask) {
|
||||
return <<<HTML
|
||||
<div class="flex flex-col">
|
||||
<a href="{$importTask->feed_url}" class="flex items-center underline hover:no-underline" target="_blank" rel="noopener noreferrer">{$importTask->feed_url}<Icon glyph="external-link" class="ml-1"/></a>
|
||||
<span class="text-sm text-gray-600">@{$importTask->handle}</span>
|
||||
</div>
|
||||
HTML;
|
||||
},
|
||||
],
|
||||
[
|
||||
'header' => lang('PodcastImport.queue.duration'),
|
||||
'cell' => function (PodcastImportTask $importTask) {
|
||||
$duration = '-';
|
||||
if ($importTask->started_at !== null) {
|
||||
if ($importTask->ended_at !== null) {
|
||||
$duration = '<div class="flex flex-col text-xs gap-y-1">' .
|
||||
'<div class="inline-flex items-center font-mono gap-x-1"><Icon glyph="timer" class="text-sm text-gray-400" />' . format_duration((int) $importTask->getDuration(), true) . '</div>' .
|
||||
'<div class="inline-flex items-center gap-x-1"><Icon glyph="calendar" class="text-sm text-gray-400" />' . relative_time($importTask->ended_at) . '</div>' .
|
||||
'</div>';
|
||||
} else {
|
||||
$duration = '<div class="inline-flex items-center font-mono text-xs gap-x-1"><Icon glyph="timer" class="text-sm text-gray-400" />' . format_duration(($importTask->started_at->difference(Time::now()))->getSeconds(), true) . '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
return $duration;
|
||||
},
|
||||
],
|
||||
[
|
||||
'header' => lang('PodcastImport.queue.imported_episodes'),
|
||||
'cell' => function (PodcastImportTask $importTask) {
|
||||
if ($importTask->episodes_count) {
|
||||
$progressPercentage = (int) ($importTask->getProgress() * 100) . '%';
|
||||
$moreInfoHelper = hint_tooltip(lang('PodcastImport.queue.imported_episodes_hint', [
|
||||
'newlyImportedCount' => $importTask->episodes_newly_imported,
|
||||
'alreadyImportedCount' => $importTask->episodes_already_imported,
|
||||
]), 'ml-1');
|
||||
return <<<HTML
|
||||
<div class="flex flex-col">
|
||||
<span>{$progressPercentage}</span>
|
||||
<p class="text-sm">
|
||||
<span class="font-semibold">{$importTask->episodes_imported}</span> out of <span class="font-semibold">{$importTask->episodes_count}</span>
|
||||
{$moreInfoHelper}
|
||||
</p>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
return '-';
|
||||
},
|
||||
],
|
||||
[
|
||||
'header' => lang('Common.list.actions'),
|
||||
'cell' => function (PodcastImportTask $importTask) {
|
||||
return '<div class="inline-flex items-center gap-x-2">' .
|
||||
'<button id="more-dropdown-' . $importTask->id . '" type="button" class="inline-flex items-center p-1 rounded-full focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $importTask->id . '-menu" aria-haspopup="true" aria-expanded="false">' .
|
||||
icon('more') .
|
||||
'</button>' .
|
||||
'<DropdownMenu id="more-dropdown-' . $importTask->id . '-menu" labelledby="more-dropdown-' . $importTask->id . '" offsetY="-24" items="' . esc(json_encode([
|
||||
[
|
||||
'type' => 'link',
|
||||
'title' => lang('PodcastImport.queue.actions.cancel'),
|
||||
'uri' => route_to('podcast-imports-task-action', $importTask->id, 'cancel'),
|
||||
],
|
||||
[
|
||||
'type' => 'link',
|
||||
'title' => lang('PodcastImport.queue.actions.retry'),
|
||||
'uri' => route_to('podcast-imports-task-action', $importTask->id, 'retry'),
|
||||
],
|
||||
[
|
||||
'type' => 'separator',
|
||||
],
|
||||
[
|
||||
'type' => 'link',
|
||||
'title' => lang('PodcastImport.queue.actions.delete'),
|
||||
'uri' => route_to('podcast-imports-task-action', $importTask->id, 'delete'),
|
||||
'class' => 'font-semibold text-red-600',
|
||||
],
|
||||
])) . '" />' .
|
||||
'</div>';
|
||||
},
|
||||
],
|
||||
],
|
||||
$podcastImportsQueue
|
||||
) ?>
|
|
@ -0,0 +1,62 @@
|
|||
<?= $this->extend('_layout') ?>
|
||||
|
||||
<?= $this->section('title') ?>
|
||||
<?= lang('Podcast.import') ?>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('pageTitle') ?>
|
||||
<?= lang('Podcast.import') ?>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<form action="<?= route_to('import') ?>" method="POST" enctype='multipart/form-data' class="flex flex-col w-full max-w-xl gap-y-8">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<Forms.Section
|
||||
title="<?= lang('PodcastImport.old_podcast_section_title') ?>">
|
||||
<Alert glyph="scales" variant="info" title="<?= lang('PodcastImport.old_podcast_legal_disclaimer_title') ?>"><?= lang('PodcastImport.old_podcast_legal_disclaimer') ?></Alert>
|
||||
<Forms.Field
|
||||
name="imported_feed_url"
|
||||
label="<?= lang('PodcastImport.imported_feed_url') ?>"
|
||||
hint="<?= lang('PodcastImport.imported_feed_url_hint') ?>"
|
||||
placeholder="https://…"
|
||||
type="url"
|
||||
required="true" />
|
||||
</Forms.Section>
|
||||
|
||||
|
||||
<Forms.Section
|
||||
title="<?= lang('PodcastImport.new_podcast_section_title') ?>" >
|
||||
|
||||
<div class="flex flex-col">
|
||||
<Forms.Label for="handle" hint="<?= lang('Podcast.form.handle_hint') ?>"><?= lang('Podcast.form.handle') ?></Forms.Label>
|
||||
<div class="relative">
|
||||
<Icon glyph="at" class="absolute inset-0 h-full text-xl opacity-40 left-3" />
|
||||
<Forms.Input name="handle" class="w-full pl-8" required="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Forms.Field
|
||||
as="Select"
|
||||
name="language"
|
||||
label="<?= lang('Podcast.form.language') ?>"
|
||||
selected="<?= $browserLang ?>"
|
||||
required="true"
|
||||
options="<?= esc(json_encode($languageOptions)) ?>" />
|
||||
|
||||
<Forms.Field
|
||||
as="Select"
|
||||
name="category"
|
||||
label="<?= lang('Podcast.form.category') ?>"
|
||||
required="true"
|
||||
options="<?= esc(json_encode($categoryOptions)) ?>" />
|
||||
|
||||
</Forms.Section>
|
||||
|
||||
<Button variant="primary" type="submit" class="self-end"><?= lang('PodcastImport.submit') ?></Button>
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
<?= $this->endSection() ?>
|