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
This commit is contained in:
Yassine Doghri 2023-06-21 16:17:11 +00:00
parent 85505d4b31
commit d8e1d4031d
106 changed files with 1806 additions and 1098 deletions

View File

@ -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',
];
/**

55
app/Config/Tasks.php Normal file
View File

@ -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');
}
}

View File

@ -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'],

View File

@ -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);

View 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);

View 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);

View 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);

View 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>

View File

@ -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
{

View File

@ -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)
';
}
}

View File

@ -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,

View File

@ -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);

View File

@ -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;

View File

@ -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

View File

@ -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 {

View File

@ -57,4 +57,14 @@
#e5e7eb 20px
);
}
.bg-stripes-warning {
background-image: repeating-linear-gradient(
-45deg,
#fde047,
#fde047 10px,
#facc15 10px,
#facc15 20px
);
}
}

View File

@ -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

View File

@ -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',

View File

@ -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

View File

@ -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));
}
}

View File

@ -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}">

View File

@ -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);

View File

@ -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"
}
]
}

129
composer.lock generated
View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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',

View File

@ -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.
*/

View File

@ -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'];

View File

@ -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;
}

View File

@ -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()

View File

@ -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;

View File

@ -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,
])
);
}
}

View File

@ -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');

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',
];

View File

@ -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(

View File

@ -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',
]);

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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,

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@ -8,7 +8,7 @@ declare(strict_types=1);
* @link https://castopod.org/
*/
namespace MediaClipper;
namespace Modules\MediaClipper;
use App\Entities\Episode;
use Exception;

View File

@ -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);
}
}

View File

@ -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',
]);
});
}
);

View File

@ -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');
}
}
}

View File

@ -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;
}
}

View File

@ -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';
}

View File

@ -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);
}
}

View File

@ -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!',
],
];

View File

@ -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' => [

View File

@ -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');
});

View File

@ -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/*

View File

@ -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') ?>

View File

@ -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',

View File

@ -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; ?>

View File

@ -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
) ?>

View File

@ -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() ?>

Some files were not shown because too many files have changed in this diff Show More