diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php
index 4f875095..f8ad08ec 100644
--- a/app/Config/Autoload.php
+++ b/app/Config/Autoload.php
@@ -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',
];
/**
diff --git a/app/Config/Tasks.php b/app/Config/Tasks.php
new file mode 100644
index 00000000..06dd531c
--- /dev/null
+++ b/app/Config/Tasks.php
@@ -0,0 +1,55 @@
+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');
+ }
+}
diff --git a/app/Controllers/WebmanifestController.php b/app/Controllers/WebmanifestController.php
index d42da053..742709a3 100644
--- a/app/Controllers/WebmanifestController.php
+++ b/app/Controllers/WebmanifestController.php
@@ -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'],
diff --git a/app/Entities/Clip/BaseClip.php b/app/Entities/Clip/BaseClip.php
index 4a2186a3..3528bfe8 100644
--- a/app/Entities/Clip/BaseClip.php
+++ b/app/Entities/Clip/BaseClip.php
@@ -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);
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index 3108afcc..e16fc81c 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -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);
diff --git a/app/Entities/Person.php b/app/Entities/Person.php
index b09aa6b6..961f5603 100644
--- a/app/Entities/Person.php
+++ b/app/Entities/Person.php
@@ -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);
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 174e50a7..3aff5e15 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -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);
diff --git a/app/Helpers/components_helper.php b/app/Helpers/components_helper.php
index 773f6360..351d2ec0 100644
--- a/app/Helpers/components_helper.php
+++ b/app/Helpers/components_helper.php
@@ -218,8 +218,8 @@ if (! function_exists('publication_status_banner')) {
}
return <<
-
+
{$bannerDisclaimer}
{$bannerText}
diff --git a/app/Helpers/misc_helper.php b/app/Helpers/misc_helper.php
index 8c26cbf8..635a07cb 100644
--- a/app/Helpers/misc_helper.php
+++ b/app/Helpers/misc_helper.php
@@ -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
{
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index e5704e66..bedb1949 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -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)
- ';
- }
}
diff --git a/app/Models/PersonModel.php b/app/Models/PersonModel.php
index 57662865..83dad162 100644
--- a/app/Models/PersonModel.php
+++ b/app/Models/PersonModel.php
@@ -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,
diff --git a/app/Models/PlatformModel.php b/app/Models/PlatformModel.php
index bb266d9e..dc9475bf 100644
--- a/app/Models/PlatformModel.php
+++ b/app/Models/PlatformModel.php
@@ -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);
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index 65fcd363..a49a0ab0 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -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;
diff --git a/app/Resources/icons/error-warning.svg b/app/Resources/icons/error-warning.svg
new file mode 100644
index 00000000..993f6e29
--- /dev/null
+++ b/app/Resources/icons/error-warning.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/app/Resources/styles/choices.css b/app/Resources/styles/choices.css
index bdaee5b7..4171ea8b 100644
--- a/app/Resources/styles/choices.css
+++ b/app/Resources/styles/choices.css
@@ -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 {
diff --git a/app/Resources/styles/custom.css b/app/Resources/styles/custom.css
index 45752db4..61b409de 100644
--- a/app/Resources/styles/custom.css
+++ b/app/Resources/styles/custom.css
@@ -57,4 +57,14 @@
#e5e7eb 20px
);
}
+
+ .bg-stripes-warning {
+ background-image: repeating-linear-gradient(
+ -45deg,
+ #fde047,
+ #fde047 10px,
+ #facc15 10px,
+ #facc15 20px
+ );
+ }
}
diff --git a/app/Views/Components/Alert.php b/app/Views/Components/Alert.php
index a653b005..462133c6 100644
--- a/app/Views/Components/Alert.php
+++ b/app/Views/Components/Alert.php
@@ -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 = '
';
+ 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 ? '' : '
' . $this->title . '
';
$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 << $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',
diff --git a/app/Views/Components/Forms/FormComponent.php b/app/Views/Components/Forms/FormComponent.php
index d8883e04..f854fb64 100644
--- a/app/Views/Components/Forms/FormComponent.php
+++ b/app/Views/Components/Forms/FormComponent.php
@@ -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
diff --git a/app/Views/Components/Forms/Input.php b/app/Views/Components/Forms/Input.php
index 4a705ed2..83712071 100644
--- a/app/Views/Components/Forms/Input.php
+++ b/app/Views/Components/Forms/Input.php
@@ -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));
}
}
diff --git a/app/Views/Components/Forms/Section.php b/app/Views/Components/Forms/Section.php
index eedd05a5..f28237d1 100644
--- a/app/Views/Components/Forms/Section.php
+++ b/app/Views/Components/Forms/Section.php
@@ -12,11 +12,9 @@ class Section extends Component
protected ?string $subtitle = null;
- protected string $subtitleClass = '';
-
public function render(): string
{
- $subtitle = $this->subtitle === null ? '' : '
' . $this->subtitle . '
';
+ $subtitle = $this->subtitle === null ? '' : '
' . $this->subtitle . '
';
return <<
diff --git a/app/Views/Components/Forms/Select.php b/app/Views/Components/Forms/Select.php
index a08db94b..6615b2e9 100644
--- a/app/Views/Components/Forms/Select.php
+++ b/app/Views/Components/Forms/Select.php
@@ -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);
diff --git a/composer.json b/composer.json
index 107f0f9b..9568676e 100644
--- a/composer.json
+++ b/composer.json
@@ -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"
+ }
+ ]
}
diff --git a/composer.lock b/composer.lock
index 740e6efb..6475e5f8 100644
--- a/composer.lock
+++ b/composer.lock
@@ -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,
diff --git a/crontab b/crontab
index 3589b3e9..660baa55 100644
--- a/crontab
+++ b/crontab
@@ -1,3 +1 @@
-* * * * * /usr/local/bin/php /castopod/public/index.php scheduled-activities
-* * * * * /usr/local/bin/php /castopod/public/index.php scheduled-video-clips
-* * * * * /usr/local/bin/php /castopod/public/index.php scheduled-websub-publish
+* * * * * /usr/local/bin/php /castopod/spark tasks:run >> /dev/null 2>&1
diff --git a/docker/production/common/crontab.txt b/docker/production/common/crontab.txt
index b1f18a1e..51409d9f 100644
--- a/docker/production/common/crontab.txt
+++ b/docker/production/common/crontab.txt
@@ -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
diff --git a/docs/src/getting-started/install.md b/docs/src/getting-started/install.md
index 03549bcc..d2eae5eb 100644
--- a/docs/src/getting-started/install.md
+++ b/docs/src/getting-started/install.md
@@ -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
diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php
index 2822d03a..106fd39f 100644
--- a/modules/Admin/Config/Routes.php
+++ b/modules/Admin/Config/Routes.php
@@ -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',
diff --git a/modules/Admin/Controllers/BaseController.php b/modules/Admin/Controllers/BaseController.php
index 667460a1..7f825ff8 100644
--- a/modules/Admin/Controllers/BaseController.php
+++ b/modules/Admin/Controllers/BaseController.php
@@ -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.
*/
diff --git a/modules/Admin/Controllers/DashboardController.php b/modules/Admin/Controllers/DashboardController.php
index c7f4769b..7d6f4821 100644
--- a/modules/Admin/Controllers/DashboardController.php
+++ b/modules/Admin/Controllers/DashboardController.php
@@ -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'];
diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php
index 0631928b..86d084ee 100644
--- a/modules/Admin/Controllers/EpisodeController.php
+++ b/modules/Admin/Controllers/EpisodeController.php
@@ -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;
}
diff --git a/modules/Admin/Controllers/PersonController.php b/modules/Admin/Controllers/PersonController.php
index dc14e804..17266de7 100644
--- a/modules/Admin/Controllers/PersonController.php
+++ b/modules/Admin/Controllers/PersonController.php
@@ -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()
diff --git a/modules/Admin/Controllers/PodcastController.php b/modules/Admin/Controllers/PodcastController.php
index 4e09219b..75fee3a9 100644
--- a/modules/Admin/Controllers/PodcastController.php
+++ b/modules/Admin/Controllers/PodcastController.php
@@ -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;
diff --git a/modules/Admin/Controllers/PodcastImportController.php b/modules/Admin/Controllers/PodcastImportController.php
deleted file mode 100644
index 85ebbde5..00000000
--- a/modules/Admin/Controllers/PodcastImportController.php
+++ /dev/null
@@ -1,701 +0,0 @@
-{$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() .
- ':
' .
- $this->request->getPost('imported_feed_url') .
- ' ⎋',
- ]);
- }
-
- $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() .
- ':
' .
- $this->request->getPost('imported_feed_url') .
- ' ⎋',
- ]);
- }
-
- $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 . '
' . $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() .
- ':
' .
- $this->podcast->imported_feed_url .
- ' ⎋',
- ]);
- }
-
- $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,
- ])
- );
- }
-}
diff --git a/modules/Admin/Controllers/SettingsController.php b/modules/Admin/Controllers/SettingsController.php
index f11bd3f5..0ba69509 100644
--- a/modules/Admin/Controllers/SettingsController.php
+++ b/modules/Admin/Controllers/SettingsController.php
@@ -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');
diff --git a/modules/Admin/Language/en/Breadcrumb.php b/modules/Admin/Language/en/Breadcrumb.php
index 558b90f7..ca682262 100644
--- a/modules/Admin/Language/en/Breadcrumb.php
+++ b/modules/Admin/Language/en/Breadcrumb.php
@@ -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',
diff --git a/modules/Admin/Language/en/Navigation.php b/modules/Admin/Language/en/Navigation.php
index 610f1434..d0ddb4c4 100644
--- a/modules/Admin/Language/en/Navigation.php
+++ b/modules/Admin/Language/en/Navigation.php
@@ -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',
diff --git a/modules/Admin/Language/en/Podcast.php b/modules/Admin/Language/en/Podcast.php
index 2d46aff5..08768f1b 100644
--- a/modules/Admin/Language/en/Podcast.php
+++ b/modules/Admin/Language/en/Podcast.php
@@ -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',
diff --git a/modules/Admin/Language/en/PodcastNavigation.php b/modules/Admin/Language/en/PodcastNavigation.php
index b4d7ddc0..a5c98b6c 100644
--- a/modules/Admin/Language/en/PodcastNavigation.php
+++ b/modules/Admin/Language/en/PodcastNavigation.php
@@ -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',
diff --git a/modules/Admin/Language/uk/PodcastImport.php b/modules/Admin/Language/uk/PodcastImport.php
deleted file mode 100644
index 7c3ef67d..00000000
--- a/modules/Admin/Language/uk/PodcastImport.php
+++ /dev/null
@@ -1,37 +0,0 @@
-
- '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',
-];
diff --git a/modules/Api/Rest/V1/Controllers/EpisodeController.php b/modules/Api/Rest/V1/Controllers/EpisodeController.php
index e10c7b8f..4e4f08eb 100644
--- a/modules/Api/Rest/V1/Controllers/EpisodeController.php
+++ b/modules/Api/Rest/V1/Controllers/EpisodeController.php
@@ -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(
diff --git a/modules/Fediverse/Controllers/SchedulerController.php b/modules/Fediverse/Commands/Broadcast.php
similarity index 70%
rename from modules/Fediverse/Controllers/SchedulerController.php
rename to modules/Fediverse/Commands/Broadcast.php
index 15a02c0b..6f74f5d9 100644
--- a/modules/Fediverse/Controllers/SchedulerController.php
+++ b/modules/Fediverse/Commands/Broadcast.php
@@ -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',
]);
diff --git a/modules/Media/Entities/Audio.php b/modules/Media/Entities/Audio.php
index 3f494e69..8ee6a70a 100644
--- a/modules/Media/Entities/Audio.php
+++ b/modules/Media/Entities/Audio.php
@@ -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;
}
diff --git a/modules/Admin/Controllers/SchedulerController.php b/modules/MediaClipper/Commands/Generate.php
similarity index 87%
rename from modules/Admin/Controllers/SchedulerController.php
rename to modules/MediaClipper/Commands/Generate.php
index eaea079f..9ceb0825 100644
--- a/modules/Admin/Controllers/SchedulerController.php
+++ b/modules/MediaClipper/Commands/Generate.php
@@ -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;
}
}
diff --git a/app/Libraries/MediaClipper/Config/MediaClipper.php b/modules/MediaClipper/Config/MediaClipper.php
similarity index 94%
rename from app/Libraries/MediaClipper/Config/MediaClipper.php
rename to modules/MediaClipper/Config/MediaClipper.php
index f0f63d32..933ed886 100644
--- a/app/Libraries/MediaClipper/Config/MediaClipper.php
+++ b/modules/MediaClipper/Config/MediaClipper.php
@@ -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
>>
@@ -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,
diff --git a/app/Libraries/MediaClipper/castopod-logo.png b/modules/MediaClipper/Resources/castopod-logo.png
similarity index 100%
rename from app/Libraries/MediaClipper/castopod-logo.png
rename to modules/MediaClipper/Resources/castopod-logo.png
diff --git a/app/Libraries/MediaClipper/fonts/Inter-Regular.otf b/modules/MediaClipper/Resources/fonts/Inter-Regular.otf
similarity index 100%
rename from app/Libraries/MediaClipper/fonts/Inter-Regular.otf
rename to modules/MediaClipper/Resources/fonts/Inter-Regular.otf
diff --git a/app/Libraries/MediaClipper/fonts/Inter-SemiBold.otf b/modules/MediaClipper/Resources/fonts/Inter-SemiBold.otf
similarity index 100%
rename from app/Libraries/MediaClipper/fonts/Inter-SemiBold.otf
rename to modules/MediaClipper/Resources/fonts/Inter-SemiBold.otf
diff --git a/app/Libraries/MediaClipper/fonts/NotoSansMono-Regular.ttf b/modules/MediaClipper/Resources/fonts/NotoSansMono-Regular.ttf
similarity index 100%
rename from app/Libraries/MediaClipper/fonts/NotoSansMono-Regular.ttf
rename to modules/MediaClipper/Resources/fonts/NotoSansMono-Regular.ttf
diff --git a/app/Libraries/MediaClipper/fonts/Rubik-Bold.ttf b/modules/MediaClipper/Resources/fonts/Rubik-Bold.ttf
similarity index 100%
rename from app/Libraries/MediaClipper/fonts/Rubik-Bold.ttf
rename to modules/MediaClipper/Resources/fonts/Rubik-Bold.ttf
diff --git a/app/Libraries/MediaClipper/quotes.png b/modules/MediaClipper/Resources/quotes.png
similarity index 100%
rename from app/Libraries/MediaClipper/quotes.png
rename to modules/MediaClipper/Resources/quotes.png
diff --git a/app/Libraries/MediaClipper/soundwaves-mask-landscape.png b/modules/MediaClipper/Resources/soundwaves-mask-landscape.png
similarity index 100%
rename from app/Libraries/MediaClipper/soundwaves-mask-landscape.png
rename to modules/MediaClipper/Resources/soundwaves-mask-landscape.png
diff --git a/app/Libraries/MediaClipper/soundwaves-mask-portrait.png b/modules/MediaClipper/Resources/soundwaves-mask-portrait.png
similarity index 100%
rename from app/Libraries/MediaClipper/soundwaves-mask-portrait.png
rename to modules/MediaClipper/Resources/soundwaves-mask-portrait.png
diff --git a/app/Libraries/MediaClipper/soundwaves-mask-squared.png b/modules/MediaClipper/Resources/soundwaves-mask-squared.png
similarity index 100%
rename from app/Libraries/MediaClipper/soundwaves-mask-squared.png
rename to modules/MediaClipper/Resources/soundwaves-mask-squared.png
diff --git a/app/Libraries/MediaClipper/VideoClipper.php b/modules/MediaClipper/VideoClipper.php
similarity index 99%
rename from app/Libraries/MediaClipper/VideoClipper.php
rename to modules/MediaClipper/VideoClipper.php
index 1372d336..a84a0492 100644
--- a/app/Libraries/MediaClipper/VideoClipper.php
+++ b/modules/MediaClipper/VideoClipper.php
@@ -8,7 +8,7 @@ declare(strict_types=1);
* @link https://castopod.org/
*/
-namespace MediaClipper;
+namespace Modules\MediaClipper;
use App\Entities\Episode;
use Exception;
diff --git a/modules/PodcastImport/Commands/PodcastImport.php b/modules/PodcastImport/Commands/PodcastImport.php
new file mode 100644
index 00000000..c5057b60
--- /dev/null
+++ b/modules/PodcastImport/Commands/PodcastImport.php
@@ -0,0 +1,541 @@
+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 tag.');
+ }
+
+ if (($coverUrl = $this->getCoverUrl($this->podcastFeed->channel)) === null) {
+ throw new Exception('Missing podcast cover. Please include an 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 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);
+ }
+}
diff --git a/modules/PodcastImport/Config/Routes.php b/modules/PodcastImport/Config/Routes.php
new file mode 100644
index 00000000..f9131da7
--- /dev/null
+++ b/modules/PodcastImport/Config/Routes.php
@@ -0,0 +1,47 @@
+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',
+ ]);
+ });
+ }
+);
diff --git a/modules/PodcastImport/Controllers/PodcastImportController.php b/modules/PodcastImport/Controllers/PodcastImportController.php
new file mode 100644
index 00000000..2854827e
--- /dev/null
+++ b/modules/PodcastImport/Controllers/PodcastImportController.php
@@ -0,0 +1,181 @@
+ 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 $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');
+ }
+ }
+}
diff --git a/modules/PodcastImport/Entities/PodcastImportTask.php b/modules/PodcastImport/Entities/PodcastImportTask.php
new file mode 100644
index 00000000..d3e92767
--- /dev/null
+++ b/modules/PodcastImport/Entities/PodcastImportTask.php
@@ -0,0 +1,242 @@
+ $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;
+ }
+}
diff --git a/modules/PodcastImport/Entities/TaskStatus.php b/modules/PodcastImport/Entities/TaskStatus.php
new file mode 100644
index 00000000..53246d0f
--- /dev/null
+++ b/modules/PodcastImport/Entities/TaskStatus.php
@@ -0,0 +1,14 @@
+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);
+ }
+}
diff --git a/modules/Admin/Language/ar/PodcastImport.php b/modules/PodcastImport/Language/ar/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/ar/PodcastImport.php
rename to modules/PodcastImport/Language/ar/PodcastImport.php
diff --git a/modules/Admin/Language/br/PodcastImport.php b/modules/PodcastImport/Language/br/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/br/PodcastImport.php
rename to modules/PodcastImport/Language/br/PodcastImport.php
diff --git a/modules/Admin/Language/ca/PodcastImport.php b/modules/PodcastImport/Language/ca/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/ca/PodcastImport.php
rename to modules/PodcastImport/Language/ca/PodcastImport.php
diff --git a/modules/Admin/Language/da/PodcastImport.php b/modules/PodcastImport/Language/da/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/da/PodcastImport.php
rename to modules/PodcastImport/Language/da/PodcastImport.php
diff --git a/modules/Admin/Language/de/PodcastImport.php b/modules/PodcastImport/Language/de/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/de/PodcastImport.php
rename to modules/PodcastImport/Language/de/PodcastImport.php
diff --git a/modules/Admin/Language/el/PodcastImport.php b/modules/PodcastImport/Language/el/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/el/PodcastImport.php
rename to modules/PodcastImport/Language/el/PodcastImport.php
diff --git a/modules/PodcastImport/Language/en/PodcastImport.php b/modules/PodcastImport/Language/en/PodcastImport.php
new file mode 100644
index 00000000..a89fe03b
--- /dev/null
+++ b/modules/PodcastImport/Language/en/PodcastImport.php
@@ -0,0 +1,60 @@
+ [
+ '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!',
+ ],
+];
diff --git a/modules/Admin/Language/es/PodcastImport.php b/modules/PodcastImport/Language/es/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/es/PodcastImport.php
rename to modules/PodcastImport/Language/es/PodcastImport.php
diff --git a/modules/Admin/Language/en/PodcastImport.php b/modules/PodcastImport/Language/fa/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/en/PodcastImport.php
rename to modules/PodcastImport/Language/fa/PodcastImport.php
diff --git a/modules/Admin/Language/fr/PodcastImport.php b/modules/PodcastImport/Language/fr/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/fr/PodcastImport.php
rename to modules/PodcastImport/Language/fr/PodcastImport.php
diff --git a/modules/Admin/Language/fa/PodcastImport.php b/modules/PodcastImport/Language/fr2/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/fa/PodcastImport.php
rename to modules/PodcastImport/Language/fr2/PodcastImport.php
diff --git a/modules/Admin/Language/fr2/PodcastImport.php b/modules/PodcastImport/Language/fr_CA/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/fr2/PodcastImport.php
rename to modules/PodcastImport/Language/fr_CA/PodcastImport.php
diff --git a/modules/Admin/Language/fr_CA/PodcastImport.php b/modules/PodcastImport/Language/fr_trad/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/fr_CA/PodcastImport.php
rename to modules/PodcastImport/Language/fr_trad/PodcastImport.php
diff --git a/modules/Admin/Language/fr_trad/PodcastImport.php b/modules/PodcastImport/Language/gd/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/fr_trad/PodcastImport.php
rename to modules/PodcastImport/Language/gd/PodcastImport.php
diff --git a/modules/Admin/Language/gl/PodcastImport.php b/modules/PodcastImport/Language/gl/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/gl/PodcastImport.php
rename to modules/PodcastImport/Language/gl/PodcastImport.php
diff --git a/modules/Admin/Language/gd/PodcastImport.php b/modules/PodcastImport/Language/id/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/gd/PodcastImport.php
rename to modules/PodcastImport/Language/id/PodcastImport.php
diff --git a/modules/Admin/Language/id/PodcastImport.php b/modules/PodcastImport/Language/it/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/id/PodcastImport.php
rename to modules/PodcastImport/Language/it/PodcastImport.php
diff --git a/modules/Admin/Language/it/PodcastImport.php b/modules/PodcastImport/Language/ko/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/it/PodcastImport.php
rename to modules/PodcastImport/Language/ko/PodcastImport.php
diff --git a/modules/Admin/Language/ko/PodcastImport.php b/modules/PodcastImport/Language/nl/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/ko/PodcastImport.php
rename to modules/PodcastImport/Language/nl/PodcastImport.php
diff --git a/modules/Admin/Language/nn-NO/PodcastImport.php b/modules/PodcastImport/Language/nn-NO/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/nn-NO/PodcastImport.php
rename to modules/PodcastImport/Language/nn-NO/PodcastImport.php
diff --git a/modules/Admin/Language/nl/PodcastImport.php b/modules/PodcastImport/Language/oc/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/nl/PodcastImport.php
rename to modules/PodcastImport/Language/oc/PodcastImport.php
diff --git a/modules/Admin/Language/pl/PodcastImport.php b/modules/PodcastImport/Language/pl/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/pl/PodcastImport.php
rename to modules/PodcastImport/Language/pl/PodcastImport.php
diff --git a/modules/Admin/Language/pt-BR/PodcastImport.php b/modules/PodcastImport/Language/pt-BR/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/pt-BR/PodcastImport.php
rename to modules/PodcastImport/Language/pt-BR/PodcastImport.php
diff --git a/modules/Admin/Language/oc/PodcastImport.php b/modules/PodcastImport/Language/pt/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/oc/PodcastImport.php
rename to modules/PodcastImport/Language/pt/PodcastImport.php
diff --git a/modules/Admin/Language/pt/PodcastImport.php b/modules/PodcastImport/Language/ro/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/pt/PodcastImport.php
rename to modules/PodcastImport/Language/ro/PodcastImport.php
diff --git a/modules/Admin/Language/ro/PodcastImport.php b/modules/PodcastImport/Language/ru/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/ro/PodcastImport.php
rename to modules/PodcastImport/Language/ru/PodcastImport.php
diff --git a/modules/Admin/Language/ru/PodcastImport.php b/modules/PodcastImport/Language/sk/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/ru/PodcastImport.php
rename to modules/PodcastImport/Language/sk/PodcastImport.php
diff --git a/modules/Admin/Language/sk/PodcastImport.php b/modules/PodcastImport/Language/sr_Latn/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/sk/PodcastImport.php
rename to modules/PodcastImport/Language/sr_Latn/PodcastImport.php
diff --git a/modules/Admin/Language/sv/PodcastImport.php b/modules/PodcastImport/Language/sv/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/sv/PodcastImport.php
rename to modules/PodcastImport/Language/sv/PodcastImport.php
diff --git a/modules/Admin/Language/sr_Latn/PodcastImport.php b/modules/PodcastImport/Language/uk/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/sr_Latn/PodcastImport.php
rename to modules/PodcastImport/Language/uk/PodcastImport.php
diff --git a/modules/Admin/Language/zh-Hans/PodcastImport.php b/modules/PodcastImport/Language/zh-Hans/PodcastImport.php
similarity index 100%
rename from modules/Admin/Language/zh-Hans/PodcastImport.php
rename to modules/PodcastImport/Language/zh-Hans/PodcastImport.php
diff --git a/modules/WebSub/Controllers/WebSubController.php b/modules/WebSub/Commands/Publish.php
similarity index 86%
rename from modules/WebSub/Controllers/WebSubController.php
rename to modules/WebSub/Commands/Publish.php
index f66cdb09..7024acf0 100644
--- a/modules/WebSub/Controllers/WebSubController.php
+++ b/modules/WebSub/Commands/Publish.php
@@ -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' => [
diff --git a/modules/WebSub/Config/Routes.php b/modules/WebSub/Config/Routes.php
deleted file mode 100644
index b86457ee..00000000
--- a/modules/WebSub/Config/Routes.php
+++ /dev/null
@@ -1,21 +0,0 @@
-group('', [
- 'namespace' => 'Modules\WebSub\Controllers',
-], static function ($routes): void {
- $routes->cli('scheduled-websub-publish', 'WebSubController::publish');
-});
diff --git a/phpstan.neon b/phpstan.neon
index b6877ff9..a666c0f4 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -7,15 +7,9 @@ parameters:
bootstrapFiles:
- vendor/codeigniter4/framework/system/Test/bootstrap.php
scanDirectories:
- - app/Helpers
- - modules/Analytics/Helpers
- - modules/Auth/Helpers
- - modules/Fediverse/Helpers
- - modules/Media/Helpers
- - modules/PremiumPodcasts/Helpers
- - vendor/codeigniter4/framework/system/Helpers
- - vendor/codeigniter4/settings/src/Helpers
- - vendor/codeigniter4/shield/src/Helpers
+ - app
+ - modules
+ - vendor/codeigniter4
excludePaths:
- app/Libraries/Router.php
- app/Views/*
diff --git a/themes/cp_admin/_layout.php b/themes/cp_admin/_layout.php
index 2cca6f23..3e642c53 100644
--- a/themes/cp_admin/_layout.php
+++ b/themes/cp_admin/_layout.php
@@ -51,9 +51,22 @@ $isEpisodeArea = isset($podcast) && isset($episode);
- publication_status !== 'published'): ?>
+
+ get('Import.current') === $podcast->handle): ?>
+