commit 158f97802100f5eddb6d425f57ff4409e61158f1
Author: Keith Solomon
Date: Mon Aug 24 08:46:51 2026 -0500
✨feature: Initial commit
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..8b7b81b
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,62 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - develop
+
+permissions:
+ contents: read
+
+jobs:
+ quality:
+ name: Composer, lint, static analysis, and unit tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: '7.4'
+ coverage: none
+ tools: composer:v2
+ - run: composer validate --strict
+ - run: composer install --no-interaction --prefer-dist
+ - run: composer lint
+ - run: composer analyse
+ - run: composer test
+
+ smoke:
+ name: WordPress activation smoke test
+ runs-on: ubuntu-latest
+ services:
+ mysql:
+ image: mysql:8.0
+ env:
+ MYSQL_DATABASE: wordpress
+ MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: '7.4'
+ coverage: none
+ tools: wp-cli
+ - name: Install WordPress
+ run: |
+ wp core download --path=/tmp/wordpress
+ wp config create --path=/tmp/wordpress --dbname=wordpress --dbuser=root --dbpass='' --dbhost=127.0.0.1
+ wp core install --path=/tmp/wordpress --url=http://example.test --title=Smoke --admin_user=admin --admin_password=password --admin_email=admin@example.test --skip-email
+ - name: Activate plugin and verify CPT
+ run: |
+ ln -s "$GITHUB_WORKSPACE" /tmp/wordpress/wp-content/plugins/podcast-rss-to-cpt
+ wp plugin activate podcast-rss-to-cpt --path=/tmp/wordpress
+ test "$(wp post-type get podcast_episode --field=name --path=/tmp/wordpress)" = "podcast_episode"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..696aa73
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/vendor/
+/.phpunit.result.cache
+/composer.lock
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..439b6d8
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,16 @@
+{
+ "workbench.colorCustomizations": {
+ "tree.indentGuidesStroke": "#3d92ec",
+ "activityBar.background": "#A02C18",
+ "titleBar.activeBackground": "#DE3F24",
+ "titleBar.activeForeground": "#FEF9F9",
+ "titleBar.inactiveBackground": "#A02C18",
+ "titleBar.inactiveForeground": "#FEF9F9",
+ "statusBar.background": "#A02C18",
+ "statusBar.foreground": "#FEF9F9",
+ "statusBar.debuggingBackground": "#A02C18",
+ "statusBar.debuggingForeground": "#FEF9F9",
+ "statusBar.noFolderBackground": "#A02C18",
+ "statusBar.noFolderForeground": "#FEF9F9"
+ }
+}
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..b02f330
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,36 @@
+{
+ "name": "rss2cpt/podcast-rss-to-cpt",
+ "description": "A lightweight WordPress podcast RSS importer for custom post types.",
+ "type": "wordpress-plugin",
+ "license": "GPL-2.0-or-later",
+ "require": {
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
+ "phpcompatibility/phpcompatibility-wp": "^2.1",
+ "php-stubs/wordpress-stubs": "^6.2",
+ "phpstan/phpstan": "^2.2",
+ "phpunit/phpunit": "^9.6",
+ "squizlabs/php_codesniffer": "^3.10",
+ "wp-coding-standards/wpcs": "^3.1"
+ },
+ "scripts": {
+ "lint": "phpcs",
+ "lint:fix": "phpcbf",
+ "analyse": "phpstan analyse --memory-limit=1G",
+ "test": "phpunit",
+ "check": [
+ "@composer validate --strict",
+ "@lint",
+ "@analyse",
+ "@test"
+ ]
+ },
+ "config": {
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true
+ },
+ "sort-packages": true
+ }
+}
diff --git a/dist/podcast-rss-to-cpt.zip b/dist/podcast-rss-to-cpt.zip
new file mode 100644
index 0000000..edd135c
Binary files /dev/null and b/dist/podcast-rss-to-cpt.zip differ
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000..9e8303a
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,21 @@
+
+
+ Project coding standards.
+ podcast-rss-to-cpt.php
+ src
+ tests
+ vendor/*
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..797e240
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,12 @@
+parameters:
+ level: 6
+ paths:
+ - podcast-rss-to-cpt.php
+ - src
+ bootstrapFiles:
+ - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php
+ - tools/phpstan-bootstrap.php
+ excludePaths:
+ analyse:
+ - vendor
+ treatPhpDocTypesAsCertain: false
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..c2f726f
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,8 @@
+
+
+
+
+ tests
+
+
+
diff --git a/podcast-rss-to-cpt.php b/podcast-rss-to-cpt.php
new file mode 100644
index 0000000..be7c0cb
--- /dev/null
+++ b/podcast-rss-to-cpt.php
@@ -0,0 +1,36 @@
+boot();
+ }
+);
diff --git a/readme.txt b/readme.txt
new file mode 100644
index 0000000..a043ba0
--- /dev/null
+++ b/readme.txt
@@ -0,0 +1,50 @@
+=== Podcast RSS to CPT ===
+Contributors: rss2cpt
+Tags: podcast, rss, importer, custom post type
+Requires at least: 6.2
+Requires PHP: 7.4
+Stable tag: 1.0.0
+License: GPLv2 or later
+License URI: https://www.gnu.org/licenses/gpl-2.0.html
+
+Import a podcast RSS feed into a configurable WordPress custom post type.
+
+== Description ==
+
+Podcast RSS to CPT imports existing and newly published podcast episodes using WordPress's bundled feed parser and WP-Cron. It can use the included Podcast Episode post type or any registered post type that supports the fields you need. Imports default to bounded batches of 25 new or changed episodes and automatically continue while scheduling is enabled.
+
+Imported data includes the title, full synopsis, excerpt, publication date, episode page URL, enclosure/audio URL and type, duration, season, episode number, explicit flag, source identifiers, and episode artwork. Artwork can be added to the media library and assigned as the featured image.
+
+== Installation ==
+
+1. Copy this directory into `wp-content/plugins/podcast-rss-to-cpt`.
+2. Activate Podcast RSS to CPT.
+3. Open Settings > Podcast RSS Import.
+4. Add the feed URL, select a target post type, and save.
+5. Use Run import now for the initial import. WP-Cron will check for new or changed episodes on the selected schedule.
+
+== Imported metadata ==
+
+Themes and integrations can use these protected post meta keys:
+
+* `_rss2cpt_source_id`
+* `_rss2cpt_source_key`
+* `_rss2cpt_feed_url`
+* `_rss2cpt_episode_url`
+* `_rss2cpt_audio_url`
+* `_rss2cpt_audio_type`
+* `_rss2cpt_audio_length`
+* `_rss2cpt_duration`
+* `_rss2cpt_duration_seconds`
+* `_rss2cpt_season`
+* `_rss2cpt_episode_number`
+* `_rss2cpt_episode_type`
+* `_rss2cpt_explicit`
+* `_rss2cpt_image_url`
+* `_rss2cpt_creator`
+
+Episode fields are registered with types for the configured post type. Public episode fields are available through the WordPress REST API; internal deduplication fingerprints remain private. Enable “Refresh feed-owned fields” if publisher changes should overwrite the imported title, synopsis, excerpt, date, status, author, metadata, and artwork; it is off by default to preserve editorial changes.
+
+== Notes ==
+
+WP-Cron runs when the site receives traffic. For reliable publishing schedules, invoke `wp-cron.php` from a real system cron. Removing the plugin keeps imported posts and downloaded media but removes plugin settings.
diff --git a/src/Admin/EpisodeMetaBox.php b/src/Admin/EpisodeMetaBox.php
new file mode 100644
index 0000000..75733bd
--- /dev/null
+++ b/src/Admin/EpisodeMetaBox.php
@@ -0,0 +1,102 @@
+ID, Importer::META_SOURCE_KEY, true );
+ if ( ! post_type_exists( $post_type ) || ( $post_type !== $configured && '' === $imported ) ) {
+ return;
+ }
+
+ add_meta_box(
+ 'rss2cpt_episode_details',
+ __( 'Podcast episode details', 'rss2cpt' ),
+ array( $this, 'render' ),
+ $post_type,
+ 'side',
+ 'default'
+ );
+ }
+
+ /**
+ * Render safely escaped, read-only source data.
+ *
+ * @param WP_Post $post Current post.
+ */
+ public function render( WP_Post $post ): void {
+ $episode_url = (string) get_post_meta( $post->ID, Importer::META_EPISODE_LINK, true );
+ $audio_url = (string) get_post_meta( $post->ID, Importer::META_AUDIO_URL, true );
+ $duration = (string) get_post_meta( $post->ID, Importer::META_DURATION, true );
+ $season = (string) get_post_meta( $post->ID, Importer::META_SEASON, true );
+ $episode = (string) get_post_meta( $post->ID, Importer::META_EPISODE, true );
+ $creator = (string) get_post_meta( $post->ID, Importer::META_CREATOR, true );
+
+ if ( '' === $episode_url && '' === $audio_url ) {
+ echo '' . esc_html__( 'No imported podcast details are available for this post.', 'rss2cpt' ) . '
';
+ return;
+ }
+
+ if ( $episode_url ) {
+ printf(
+ '%2$s
',
+ esc_url( $episode_url ),
+ esc_html__( 'View original episode', 'rss2cpt' )
+ );
+ }
+
+ if ( $audio_url ) {
+ printf(
+ '%2$s
',
+ esc_url( $audio_url ),
+ esc_html__( 'Open audio file', 'rss2cpt' )
+ );
+ }
+
+ $details = array_filter(
+ array(
+ __( 'Duration', 'rss2cpt' ) => $duration,
+ __( 'Season', 'rss2cpt' ) => $season,
+ __( 'Episode', 'rss2cpt' ) => $episode,
+ __( 'Creator', 'rss2cpt' ) => $creator,
+ )
+ );
+
+ if ( $details ) {
+ echo '';
+ foreach ( $details as $label => $value ) {
+ printf( '%1$s %2$s ', esc_html( $label ), esc_html( $value ) );
+ }
+ echo ' ';
+ }
+ }
+}
diff --git a/src/Admin/Settings.php b/src/Admin/Settings.php
new file mode 100644
index 0000000..ce1c0f8
--- /dev/null
+++ b/src/Admin/Settings.php
@@ -0,0 +1,567 @@
+importer = $importer;
+ $this->scheduler = $scheduler;
+ }
+
+ /**
+ * Register WordPress hooks.
+ *
+ * @return void
+ */
+ public function register(): void {
+ add_action( 'admin_menu', array( $this, 'register_page' ) );
+ add_action( 'admin_init', array( $this, 'register_settings' ) );
+ add_action( 'admin_post_' . self::MANUAL_IMPORT_ACTION, array( $this, 'handle_manual_import' ) );
+ add_action( 'admin_notices', array( $this, 'render_notices' ) );
+ $this->scheduler->ensure_scheduled();
+ }
+
+ /**
+ * Register the settings page beneath Settings.
+ *
+ * @return void
+ */
+ public function register_page(): void {
+ add_options_page(
+ esc_html__( 'Podcast RSS Import', 'rss2cpt' ),
+ esc_html__( 'Podcast RSS Import', 'rss2cpt' ),
+ 'manage_options',
+ self::PAGE_SLUG,
+ array( $this, 'render_page' )
+ );
+ }
+
+ /**
+ * Register the option, section, and fields.
+ *
+ * @return void
+ */
+ public function register_settings(): void {
+ register_setting(
+ 'rss2cpt_settings_group',
+ Options::KEY,
+ array(
+ 'type' => 'array',
+ 'default' => Options::get(),
+ 'sanitize_callback' => array( $this, 'sanitize_settings' ),
+ )
+ );
+
+ add_settings_section(
+ 'rss2cpt_source_section',
+ esc_html__( 'Import configuration', 'rss2cpt' ),
+ array( $this, 'render_section_description' ),
+ self::PAGE_SLUG
+ );
+
+ $fields = array(
+ 'feed_url' => __( 'Podcast feed URL', 'rss2cpt' ),
+ 'post_type' => __( 'Target post type', 'rss2cpt' ),
+ 'schedule' => __( 'Import schedule', 'rss2cpt' ),
+ 'post_status' => __( 'Imported post status', 'rss2cpt' ),
+ 'author_id' => __( 'Post author', 'rss2cpt' ),
+ 'import_image' => __( 'Episode images', 'rss2cpt' ),
+ 'update_existing' => __( 'Existing episodes', 'rss2cpt' ),
+ 'item_limit' => __( 'Per-run import limit', 'rss2cpt' ),
+ );
+
+ foreach ( $fields as $field => $label ) {
+ add_settings_field(
+ 'rss2cpt_' . $field,
+ esc_html( $label ),
+ array( $this, 'render_' . $field . '_field' ),
+ self::PAGE_SLUG,
+ 'rss2cpt_source_section'
+ );
+ }
+ }
+
+ /**
+ * Return normalized saved settings.
+ *
+ * @return array Settings values.
+ */
+ public function get_settings(): array {
+ return Options::get();
+ }
+
+ /**
+ * Sanitize settings before storage.
+ *
+ * @param mixed $input Submitted settings.
+ * @return array Sanitized settings.
+ */
+ public function sanitize_settings( $input ): array {
+ $current = $this->get_settings();
+ $input = is_array( $input ) ? $input : array();
+ $output = Options::get();
+
+ $feed_url = isset( $input['feed_url'] ) ? esc_url_raw( wp_unslash( $input['feed_url'] ), array( 'http', 'https' ) ) : '';
+ if ( '' !== $feed_url && ! wp_http_validate_url( $feed_url ) ) {
+ add_settings_error(
+ Options::KEY,
+ 'rss2cpt_invalid_feed_url',
+ esc_html__( 'Enter a valid HTTP or HTTPS podcast feed URL.', 'rss2cpt' )
+ );
+ $feed_url = (string) $current['feed_url'];
+ }
+ $output['feed_url'] = $feed_url;
+
+ $post_type = isset( $input['post_type'] ) ? sanitize_key( $input['post_type'] ) : '';
+ if ( ! $this->is_allowed_post_type( $post_type ) ) {
+ add_settings_error(
+ Options::KEY,
+ 'rss2cpt_invalid_post_type',
+ esc_html__( 'Select a post type that is available in the WordPress admin.', 'rss2cpt' )
+ );
+ $post_type = $this->is_allowed_post_type( (string) $current['post_type'] ) ? (string) $current['post_type'] : 'podcast_episode';
+ }
+ $output['post_type'] = $post_type;
+
+ $schedules = wp_get_schedules();
+ $schedule = isset( $input['schedule'] ) ? sanitize_key( $input['schedule'] ) : '';
+ if ( 'disabled' !== $schedule && ! isset( $schedules[ $schedule ] ) ) {
+ $schedule = ( 'disabled' === $current['schedule'] || isset( $schedules[ $current['schedule'] ] ) ) ? (string) $current['schedule'] : 'hourly';
+ }
+ $output['schedule'] = $schedule;
+
+ $allowed_statuses = array( 'draft', 'pending', 'private', 'publish' );
+ $post_status = isset( $input['post_status'] ) ? sanitize_key( $input['post_status'] ) : '';
+ $output['post_status'] = in_array( $post_status, $allowed_statuses, true ) ? $post_status : 'draft';
+
+ $author_id = isset( $input['author_id'] ) ? absint( $input['author_id'] ) : 0;
+ if ( 0 === $author_id ) {
+ $author_id = get_current_user_id();
+ }
+ if ( 0 !== $author_id && ! get_user_by( 'id', $author_id ) ) {
+ $author_id = 0;
+ }
+ $output['author_id'] = $author_id;
+
+ $output['import_image'] = empty( $input['import_image'] ) ? 0 : 1;
+ $output['update_existing'] = empty( $input['update_existing'] ) ? 0 : 1;
+ $output['item_limit'] = isset( $input['item_limit'] ) ? min( 500, absint( $input['item_limit'] ) ) : 0;
+
+ return $output;
+ }
+
+ /**
+ * Render the settings page.
+ *
+ * @return void
+ */
+ public function render_page(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ return;
+ }
+ ?>
+
+
+ render_last_import(); ?>
+
+
+
+
+
+
+
+ ' . esc_html( $message ) . '
';
+ }
+
+ /**
+ * Handle a verified manual import request.
+ *
+ * @return void
+ */
+ public function handle_manual_import(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die(
+ esc_html__( 'You are not allowed to run podcast imports.', 'rss2cpt' ),
+ esc_html__( 'Forbidden', 'rss2cpt' ),
+ array( 'response' => 403 )
+ );
+ }
+
+ check_admin_referer( self::NONCE_ACTION );
+
+ $result = array();
+
+ try {
+ $result = $this->importer->import( Options::get() );
+ } catch ( Throwable $exception ) {
+ // Expose the exception to opt-in logging without leaking details in the URL.
+ do_action( 'rss2cpt_manual_import_exception', $exception );
+ $this->scheduler->record_result( new \WP_Error( 'rss2cpt_import_exception', __( 'The importer stopped unexpectedly.', 'rss2cpt' ) ) );
+ $this->redirect_with_notice( 'error' );
+ }
+ $this->scheduler->record_result( $result );
+
+ if ( is_wp_error( $result ) ) {
+ $this->redirect_with_notice( 'error' );
+ }
+ if ( is_array( $result ) && ! empty( $result['has_more'] ) ) {
+ $this->scheduler->schedule_continuation();
+ }
+
+ $counts = $this->normalize_import_result( $result );
+ $this->redirect_with_notice( 'success', $counts );
+ }
+
+ /**
+ * Render a manual-import result notice.
+ *
+ * @return void
+ */
+ public function render_notices(): void {
+ if ( ! current_user_can( 'manage_options' ) || ! $this->is_settings_screen() ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
+ $notice = isset( $_GET['rss2cpt_notice'] ) ? sanitize_key( wp_unslash( $_GET['rss2cpt_notice'] ) ) : '';
+ if ( '' === $notice ) {
+ return;
+ }
+
+ $class = 'notice notice-error';
+ $message = __( 'The podcast import could not be completed. Review the last import status above.', 'rss2cpt' );
+
+ if ( 'success' === $notice ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
+ $imported = isset( $_GET['imported'] ) ? absint( $_GET['imported'] ) : 0;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
+ $updated = isset( $_GET['updated'] ) ? absint( $_GET['updated'] ) : 0;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
+ $skipped = isset( $_GET['skipped'] ) ? absint( $_GET['skipped'] ) : 0;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
+ $failed = isset( $_GET['failed'] ) ? absint( $_GET['failed'] ) : 0;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
+ $warnings = isset( $_GET['warnings'] ) ? absint( $_GET['warnings'] ) : 0;
+
+ $class = 0 < ( $failed + $warnings ) ? 'notice notice-warning' : 'notice notice-success';
+ $message = sprintf(
+ /* translators: 1: imported count, 2: updated count, 3: skipped count, 4: failed count, 5: warning count. */
+ __( 'Podcast import finished: %1$d imported, %2$d updated, %3$d skipped, %4$d failed, %5$d warnings.', 'rss2cpt' ),
+ $imported,
+ $updated,
+ $skipped,
+ $failed,
+ $warnings
+ );
+ }
+ ?>
+
+ ' . esc_html__( 'Choose the feed source and how episodes should be created. Scheduled changes take effect when the scheduler next synchronizes.', 'rss2cpt' ) . '';
+ }
+
+ /** Render feed URL field. */
+ public function render_feed_url_field(): void {
+ $value = $this->get_settings()['feed_url'];
+ printf(
+ '',
+ esc_attr( Options::KEY ),
+ esc_attr( $value ),
+ esc_html__( 'The public HTTP or HTTPS RSS feed. Only episodes retained in the publisher’s feed can be imported.', 'rss2cpt' )
+ );
+ }
+
+ /** Render target post type field. */
+ public function render_post_type_field(): void {
+ $value = (string) $this->get_settings()['post_type'];
+ $post_types = get_post_types( array( 'show_ui' => true ), 'objects' );
+
+ echo '';
+ }
+
+ /** Render schedule field. */
+ public function render_schedule_field(): void {
+ $value = (string) $this->get_settings()['schedule'];
+ $schedules = wp_get_schedules();
+
+ echo '';
+ }
+
+ /** Render post status field. */
+ public function render_post_status_field(): void {
+ $value = (string) $this->get_settings()['post_status'];
+ $statuses = array(
+ 'draft' => __( 'Draft', 'rss2cpt' ),
+ 'pending' => __( 'Pending review', 'rss2cpt' ),
+ 'private' => __( 'Private', 'rss2cpt' ),
+ 'publish' => __( 'Published', 'rss2cpt' ),
+ );
+
+ echo '';
+ }
+
+ /** Render author field. */
+ public function render_author_id_field(): void {
+ wp_dropdown_users(
+ array(
+ 'id' => 'rss2cpt_author_id',
+ 'name' => Options::KEY . '[author_id]',
+ 'selected' => absint( $this->get_settings()['author_id'] ),
+ 'show_option_none' => __( 'Current administrator', 'rss2cpt' ),
+ 'option_none_value' => 0,
+ 'who' => 'authors',
+ )
+ );
+ }
+
+ /** Render image-import field. */
+ public function render_import_image_field(): void {
+ $value = absint( $this->get_settings()['import_image'] );
+ printf(
+ ' %3$s ',
+ esc_attr( Options::KEY ),
+ checked( 1, $value, false ),
+ esc_html__( 'Download episode artwork to the Media Library and use it as the featured image.', 'rss2cpt' )
+ );
+ }
+
+ /** Render existing-episode update field. */
+ public function render_update_existing_field(): void {
+ $value = absint( $this->get_settings()['update_existing'] );
+ printf(
+ ' %3$s ',
+ esc_attr( Options::KEY ),
+ checked( 1, $value, false ),
+ esc_html__( 'Refresh feed-owned title, synopsis, date, status, author, metadata, and changed artwork when an episode changes.', 'rss2cpt' )
+ );
+ }
+
+ /** Render per-run item limit field. */
+ public function render_item_limit_field(): void {
+ $value = absint( $this->get_settings()['item_limit'] );
+ printf(
+ '',
+ esc_attr( Options::KEY ),
+ esc_attr( (string) $value ),
+ esc_html__( 'Maximum new or changed episodes saved per run (0–500). Enter 0 to process all episodes currently available in the feed.', 'rss2cpt' )
+ );
+ }
+
+ /**
+ * Determine whether a post type is a valid import target.
+ *
+ * @param string $post_type Post type name.
+ * @return bool
+ */
+ private function is_allowed_post_type( string $post_type ): bool {
+ $object = get_post_type_object( $post_type );
+
+ return null !== $object
+ && ! empty( $object->show_ui )
+ && isset( $object->cap->edit_posts )
+ && current_user_can( $object->cap->edit_posts );
+ }
+
+ /**
+ * Normalize supported importer return values into notice counts.
+ *
+ * @param mixed $result Importer result.
+ * @return array Import counters.
+ */
+ private function normalize_import_result( $result ): array {
+ $counts = array(
+ 'imported' => 0,
+ 'updated' => 0,
+ 'skipped' => 0,
+ 'failed' => 0,
+ 'warnings' => 0,
+ );
+
+ if ( is_numeric( $result ) ) {
+ $counts['imported'] = absint( $result );
+ return $counts;
+ }
+
+ if ( is_array( $result ) ) {
+ if ( isset( $result['created'] ) ) {
+ $counts['imported'] = absint( $result['created'] );
+ }
+ foreach ( array_keys( $counts ) as $key ) {
+ if ( isset( $result[ $key ] ) ) {
+ $counts[ $key ] = absint( $result[ $key ] );
+ }
+ }
+ }
+
+ return $counts;
+ }
+
+ /**
+ * Redirect to the settings screen with a result notice.
+ *
+ * @param string $notice Notice key.
+ * @param array $counts Optional import counters.
+ * @return void
+ */
+ private function redirect_with_notice( string $notice, array $counts = array() ): void {
+ $query = array_merge(
+ array(
+ 'page' => self::PAGE_SLUG,
+ 'rss2cpt_notice' => sanitize_key( $notice ),
+ ),
+ array_map( 'absint', $counts )
+ );
+
+ wp_safe_redirect( add_query_arg( $query, admin_url( 'options-general.php' ) ) );
+ exit;
+ }
+
+ /**
+ * Check whether the current request is for this settings screen.
+ *
+ * @return bool
+ */
+ private function is_settings_screen(): bool {
+ $screen = get_current_screen();
+
+ return null !== $screen && 'settings_page_' . self::PAGE_SLUG === $screen->id;
+ }
+}
diff --git a/src/Import/Importer.php b/src/Import/Importer.php
new file mode 100644
index 0000000..af548eb
--- /dev/null
+++ b/src/Import/Importer.php
@@ -0,0 +1,804 @@
+ $settings Import settings.
+ * @return array|WP_Error Structured import result or a feed/configuration error.
+ */
+ public function import( array $settings ) {
+ $settings = wp_parse_args(
+ $settings,
+ array(
+ 'feed_url' => '',
+ 'post_type' => '',
+ 'post_status' => 'publish',
+ 'author_id' => 0,
+ 'import_image' => true,
+ 'update_existing' => false,
+ 'item_limit' => 0,
+ )
+ );
+ $feed_url = esc_url_raw( (string) $settings['feed_url'] );
+ $this->post_type = sanitize_key( (string) $settings['post_type'] );
+ $result = $this->new_result( $feed_url );
+
+ if ( empty( $feed_url ) || ! wp_http_validate_url( $feed_url ) ) {
+ return new WP_Error( 'rss2cpt_invalid_feed_url', __( 'The podcast feed URL is invalid.', 'rss2cpt' ) );
+ }
+
+ if ( empty( $this->post_type ) || ! post_type_exists( $this->post_type ) ) {
+ return new WP_Error( 'rss2cpt_invalid_post_type', __( 'The configured destination post type does not exist.', 'rss2cpt' ) );
+ }
+
+ /**
+ * Filters options immediately before a podcast feed import.
+ *
+ * @param array $options Import options.
+ * @param string $feed_url Feed URL.
+ * @param string $post_type Destination post type.
+ */
+ $settings = (array) apply_filters( 'rss2cpt_import_options', $settings, $feed_url, $this->post_type );
+ $options = $this->sanitize_options( $settings );
+
+ $lock_key = 'rss2cpt_lock_' . md5( $feed_url . '|' . $this->post_type );
+ $lock_token = $this->acquire_lock( $lock_key );
+ if ( false === $lock_token ) {
+ return new WP_Error( 'rss2cpt_import_locked', __( 'This podcast feed is already being imported.', 'rss2cpt' ) );
+ }
+
+ try {
+ if ( ! function_exists( 'fetch_feed' ) ) {
+ require_once ABSPATH . WPINC . '/feed.php';
+ }
+
+ $this->remote_size_limit = self::MAX_FEED_BYTES;
+ add_filter( 'http_request_args', array( $this, 'limit_remote_response_size' ), 10, 2 );
+ add_filter( 'wp_feed_cache_transient_lifetime', array( $this, 'filter_feed_cache_lifetime' ), 10, 2 );
+ try {
+ $feed = fetch_feed( $feed_url );
+ } finally {
+ remove_filter( 'wp_feed_cache_transient_lifetime', array( $this, 'filter_feed_cache_lifetime' ), 10 );
+ remove_filter( 'http_request_args', array( $this, 'limit_remote_response_size' ), 10 );
+ $this->remote_size_limit = 0;
+ }
+ if ( is_wp_error( $feed ) ) {
+ return $feed;
+ }
+
+ $items = $feed->get_items( 0, $feed->get_item_quantity() );
+ $result['total'] = count( $items );
+
+ foreach ( $items as $index => $item ) {
+ $this->renew_lock( $lock_key, $lock_token );
+ $item_result = $this->import_item( $item, $feed, $feed_url, $options );
+ $status = $item_result['status'];
+
+ if ( isset( $result[ $status ] ) ) {
+ ++$result[ $status ];
+ }
+
+ if ( ! empty( $item_result['post_id'] ) ) {
+ $result['post_ids'][ $status ][] = $item_result['post_id'];
+ }
+
+ if ( ! empty( $item_result['error'] ) ) {
+ $result['errors'][] = $item_result['error'];
+ if ( isset( $item_result['error']['severity'] ) && 'warning' === $item_result['error']['severity'] ) {
+ ++$result['warnings'];
+ }
+ }
+
+ $mutated = $result['created'] + $result['updated'];
+ if ( 0 < $options['item_limit'] && $mutated >= $options['item_limit'] ) {
+ $result['has_more'] = ( $index + 1 ) < count( $items );
+ break;
+ }
+ }
+
+ /**
+ * Filters the completed podcast import result.
+ *
+ * @param array $result Structured import result.
+ * @param string $feed_url Feed URL.
+ * @param string $post_type Destination post type.
+ */
+ return (array) apply_filters( 'rss2cpt_import_result', $result, $feed_url, $this->post_type );
+ } finally {
+ $this->release_lock( $lock_key, $lock_token );
+ }
+ }
+
+ /**
+ * Add a byte cap to a plugin-scoped remote request.
+ *
+ * @param array $args HTTP request arguments.
+ * @param string $url Requested URL.
+ * @return array Filtered arguments.
+ */
+ public function limit_remote_response_size( array $args, string $url ): array {
+ unset( $url );
+ if ( 0 < $this->remote_size_limit ) {
+ $args['limit_response_size'] = $this->remote_size_limit;
+ }
+
+ return $args;
+ }
+
+ /**
+ * Keep recurring imports fresher than WordPress's long default feed cache.
+ *
+ * @param int $lifetime Default cache lifetime.
+ * @param string $url Feed URL.
+ * @return int Cache lifetime in seconds.
+ */
+ public function filter_feed_cache_lifetime( int $lifetime, string $url ): int {
+ unset( $lifetime, $url );
+ return 5 * MINUTE_IN_SECONDS;
+ }
+
+ /**
+ * Import one SimplePie item.
+ *
+ * @param object $item SimplePie item.
+ * @param object $feed SimplePie feed.
+ * @param string $feed_url Feed URL.
+ * @param array $options Sanitized import options.
+ * @return array Item result.
+ */
+ private function import_item( $item, $feed, string $feed_url, array $options ): array {
+ $data = $this->map_item( $item, $feed );
+
+ /**
+ * Filters a mapped podcast item before it is persisted.
+ *
+ * @param array $data Mapped item data.
+ * @param object $item SimplePie item.
+ * @param string $feed_url Feed URL.
+ * @param string $post_type Destination post type.
+ */
+ $data = (array) apply_filters( 'rss2cpt_import_item_data', $data, $item, $feed_url, $this->post_type );
+ unset( $data['fingerprint'] );
+ $data['fingerprint'] = hash( 'sha256', (string) wp_json_encode( $data ) );
+
+ if ( empty( $data['title'] ) || empty( $data['source_key'] ) ) {
+ return array(
+ 'status' => 'failed',
+ 'post_id' => 0,
+ 'error' => $this->format_error(
+ new WP_Error( 'rss2cpt_invalid_item', __( 'A feed item is missing a title or stable identifier.', 'rss2cpt' ) ),
+ $data['source_id'] ?? ''
+ ),
+ );
+ }
+
+ $existing_id = $this->find_existing_post( $data['source_key'] );
+ if ( $existing_id && ! $options['update_existing'] ) {
+ if ( $options['import_image'] && ! empty( $data['image_url'] ) && ! has_post_thumbnail( $existing_id ) ) {
+ $image_result = $this->set_episode_image( $existing_id, $data['image_url'], $data['title'] );
+ if ( is_wp_error( $image_result ) ) {
+ return array(
+ 'status' => 'skipped',
+ 'post_id' => $existing_id,
+ 'error' => $this->format_error( $image_result, $data['source_id'], $existing_id, 'warning' ),
+ );
+ }
+
+ return array(
+ 'status' => 'updated',
+ 'post_id' => $existing_id,
+ 'error' => null,
+ );
+ }
+
+ return array(
+ 'status' => 'skipped',
+ 'post_id' => $existing_id,
+ 'error' => null,
+ );
+ }
+ $needs_image = $existing_id
+ && $options['import_image']
+ && ! empty( $data['image_url'] )
+ && (
+ ! has_post_thumbnail( $existing_id )
+ || (string) get_post_meta( $existing_id, self::META_IMAGE_URL, true ) !== $data['image_url']
+ );
+
+ if ( $existing_id && ! $needs_image && hash_equals( (string) get_post_meta( $existing_id, self::META_FINGERPRINT, true ), $data['fingerprint'] ) ) {
+ return array(
+ 'status' => 'skipped',
+ 'post_id' => $existing_id,
+ 'error' => null,
+ );
+ }
+
+ $postarr = array(
+ 'post_type' => $this->post_type,
+ 'post_status' => $options['post_status'],
+ 'post_author' => $options['post_author'],
+ 'post_title' => $data['title'],
+ 'post_content' => $data['content'],
+ 'post_excerpt' => $data['excerpt'],
+ 'meta_input' => $this->build_meta_input( $data, $feed_url ),
+ );
+
+ if ( ! empty( $data['timestamp'] ) ) {
+ $postarr['post_date_gmt'] = gmdate( 'Y-m-d H:i:s', $data['timestamp'] );
+ $postarr['post_date'] = get_date_from_gmt( $postarr['post_date_gmt'] );
+ }
+
+ if ( $existing_id ) {
+ $postarr['ID'] = $existing_id;
+ $post_id = wp_update_post( wp_slash( $postarr ), true );
+ $status = 'updated';
+ } else {
+ // Recheck immediately before insertion to narrow cross-process races.
+ $existing_id = $this->find_existing_post( $data['source_key'] );
+ if ( $existing_id ) {
+ $postarr['ID'] = $existing_id;
+ $post_id = wp_update_post( wp_slash( $postarr ), true );
+ $status = 'updated';
+ } else {
+ $post_id = wp_insert_post( wp_slash( $postarr ), true );
+ $status = 'created';
+ }
+ }
+
+ if ( is_wp_error( $post_id ) ) {
+ return array(
+ 'status' => 'failed',
+ 'post_id' => 0,
+ 'error' => $this->format_error( $post_id, $data['source_id'] ),
+ );
+ }
+
+ $error = null;
+ if ( $options['import_image'] && ! empty( $data['image_url'] ) ) {
+ $image_result = $this->set_episode_image( (int) $post_id, $data['image_url'], $data['title'] );
+ if ( is_wp_error( $image_result ) ) {
+ $error = $this->format_error( $image_result, $data['source_id'], (int) $post_id, 'warning' );
+ delete_post_meta( $post_id, self::META_FINGERPRINT );
+ }
+ } elseif ( ! empty( $data['image_url'] ) ) {
+ update_post_meta( $post_id, self::META_IMAGE_URL, $data['image_url'] );
+ }
+
+ /**
+ * Fires after a podcast item is successfully persisted.
+ *
+ * @param int $post_id Imported post ID.
+ * @param array $data Mapped item data.
+ * @param string $status Either created or updated.
+ * @param object $item SimplePie item.
+ */
+ do_action( 'rss2cpt_imported_item', (int) $post_id, $data, $status, $item );
+
+ return array(
+ 'status' => $status,
+ 'post_id' => (int) $post_id,
+ 'error' => $error,
+ );
+ }
+
+ /**
+ * Map SimplePie values to a normalized episode array.
+ *
+ * @param object $item SimplePie item.
+ * @param object $feed SimplePie feed.
+ * @return array Normalized episode data.
+ */
+ private function map_item( $item, $feed ): array {
+ $guid = trim( (string) $item->get_id() );
+ $link = esc_url_raw( (string) $item->get_link() );
+ $enclosure = $item->get_enclosure();
+ $audio_url = $enclosure ? esc_url_raw( (string) $enclosure->get_link() ) : '';
+ $source_id = $guid;
+ $timestamp = (int) $item->get_date( 'U' );
+ $title = sanitize_text_field( (string) $item->get_title() );
+ $content = (string) $item->get_content();
+ $summary = $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'summary' );
+ $subtitle = $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'subtitle' );
+ $description = (string) $item->get_description();
+ $image_url = $this->get_episode_image_url( $item );
+
+ if ( empty( $source_id ) ) {
+ $source_id = ! empty( $audio_url ) ? $audio_url : $link;
+ }
+ if ( empty( $source_id ) ) {
+ $source_id = hash( 'sha256', $title . '|' . $timestamp );
+ }
+ if ( empty( $title ) ) {
+ $title = $timestamp
+ ? sprintf(
+ /* translators: %s: Episode publication date. */
+ __( 'Podcast episode from %s', 'rss2cpt' ),
+ wp_date( get_option( 'date_format' ), $timestamp )
+ )
+ : __( 'Untitled podcast episode', 'rss2cpt' );
+ }
+
+ if ( empty( $content ) ) {
+ $content = ! empty( $summary ) ? $summary : $description;
+ }
+
+ $excerpt = $summary;
+ if ( empty( $excerpt ) ) {
+ $excerpt = ! empty( $subtitle ) ? $subtitle : $description;
+ }
+
+ if ( empty( $image_url ) && method_exists( $feed, 'get_image_url' ) ) {
+ $image_url = esc_url_raw( (string) $feed->get_image_url() );
+ }
+
+ $duration = sanitize_text_field( $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'duration' ) );
+ $explicit = strtolower( sanitize_text_field( $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'explicit' ) ) );
+
+ $data = array(
+ 'title' => $title,
+ 'content' => wp_kses_post( $content ),
+ 'excerpt' => sanitize_textarea_field( wp_strip_all_tags( $excerpt ) ),
+ 'timestamp' => $timestamp,
+ 'link' => $link,
+ 'audio_url' => $audio_url,
+ 'audio_type' => $enclosure ? sanitize_mime_type( (string) $enclosure->get_type() ) : '',
+ 'audio_length' => $enclosure ? absint( $enclosure->get_length() ) : 0,
+ 'duration' => $duration,
+ 'duration_seconds' => $this->duration_to_seconds( $duration ),
+ 'season' => absint( $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'season' ) ),
+ 'episode' => absint( $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'episode' ) ),
+ 'episode_type' => sanitize_key( $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'episodeType' ) ),
+ 'explicit' => in_array( $explicit, array( 'yes', 'true', 'explicit' ), true ) ? '1' : '0',
+ 'image_url' => $image_url,
+ 'source_id' => sanitize_text_field( $source_id ),
+ 'source_key' => $source_id ? hash( 'sha256', $source_id ) : '',
+ 'creator' => sanitize_text_field( $this->get_tag_value( $item, self::DC_NAMESPACE, 'creator' ) ),
+ );
+
+ $data['fingerprint'] = hash( 'sha256', (string) wp_json_encode( $data ) );
+
+ return $data;
+ }
+
+ /**
+ * Build protected post metadata for an episode.
+ *
+ * @param array $data Normalized item data.
+ * @param string $feed_url Feed URL.
+ * @return array Post metadata.
+ */
+ private function build_meta_input( array $data, string $feed_url ): array {
+ return array(
+ self::META_SOURCE_KEY => $data['source_key'],
+ self::META_FEED_URL => $feed_url,
+ self::META_SOURCE_ID => $data['source_id'],
+ self::META_EPISODE_LINK => $data['link'],
+ self::META_AUDIO_URL => $data['audio_url'],
+ self::META_AUDIO_TYPE => $data['audio_type'],
+ self::META_AUDIO_LENGTH => $data['audio_length'],
+ self::META_DURATION => $data['duration'],
+ self::META_DURATION_SECONDS => $data['duration_seconds'],
+ self::META_SEASON => $data['season'],
+ self::META_EPISODE => $data['episode'],
+ self::META_EPISODE_TYPE => $data['episode_type'],
+ self::META_EXPLICIT => $data['explicit'],
+ self::META_FINGERPRINT => $data['fingerprint'],
+ self::META_CREATOR => $data['creator'],
+ );
+ }
+
+ /**
+ * Find a previously imported episode by stable source key.
+ *
+ * @param string $source_key Stable source key.
+ * @return int Post ID, or zero when not found.
+ */
+ private function find_existing_post( string $source_key ): int {
+ $query = new WP_Query(
+ array(
+ 'post_type' => $this->post_type,
+ 'post_status' => 'any',
+ 'posts_per_page' => 1,
+ 'fields' => 'ids',
+ 'no_found_rows' => true,
+ 'update_post_meta_cache' => false,
+ 'update_post_term_cache' => false,
+ // A protected source key is the canonical cross-run identity.
+ 'meta_key' => self::META_SOURCE_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ 'meta_value' => $source_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ )
+ );
+
+ return empty( $query->posts ) ? 0 : (int) $query->posts[0];
+ }
+
+ /**
+ * Sideload and attach a changed episode image.
+ *
+ * @param int $post_id Episode post ID.
+ * @param string $image_url Remote image URL.
+ * @param string $title Episode title.
+ * @return int|WP_Error Attachment ID or error.
+ */
+ private function set_episode_image( int $post_id, string $image_url, string $title ) {
+ $current_url = (string) get_post_meta( $post_id, self::META_IMAGE_URL, true );
+ if ( has_post_thumbnail( $post_id ) && $current_url === $image_url ) {
+ return (int) get_post_thumbnail_id( $post_id );
+ }
+
+ if ( ! function_exists( 'media_handle_sideload' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/media.php';
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+ }
+
+ $this->remote_size_limit = self::MAX_IMAGE_BYTES;
+ add_filter( 'http_request_args', array( $this, 'limit_remote_response_size' ), 10, 2 );
+ try {
+ $temp_file = download_url( $image_url, 30 );
+ } finally {
+ remove_filter( 'http_request_args', array( $this, 'limit_remote_response_size' ), 10 );
+ $this->remote_size_limit = 0;
+ }
+
+ if ( is_wp_error( $temp_file ) ) {
+ return $temp_file;
+ }
+
+ if ( ! file_exists( $temp_file ) || filesize( $temp_file ) >= self::MAX_IMAGE_BYTES ) {
+ wp_delete_file( $temp_file );
+ return new WP_Error( 'rss2cpt_image_too_large', __( 'The episode image exceeds the ten megabyte limit.', 'rss2cpt' ) );
+ }
+
+ $image_mime = wp_get_image_mime( $temp_file );
+ $extensions = array(
+ 'image/jpeg' => 'jpg',
+ 'image/png' => 'png',
+ 'image/gif' => 'gif',
+ 'image/webp' => 'webp',
+ 'image/avif' => 'avif',
+ );
+ if ( ! $image_mime || ! isset( $extensions[ $image_mime ] ) ) {
+ wp_delete_file( $temp_file );
+ return new WP_Error( 'rss2cpt_invalid_image', __( 'The episode artwork is not a supported image.', 'rss2cpt' ) );
+ }
+
+ $dimensions = wp_getimagesize( $temp_file );
+ if ( ! $dimensions || $dimensions[0] > 10000 || $dimensions[1] > 10000 ) {
+ wp_delete_file( $temp_file );
+ return new WP_Error( 'rss2cpt_invalid_image_dimensions', __( 'The episode artwork dimensions are invalid or too large.', 'rss2cpt' ) );
+ }
+
+ $file_array = array(
+ 'name' => sanitize_file_name( $title . '.' . $extensions[ $image_mime ] ),
+ 'tmp_name' => $temp_file,
+ );
+ $attachment_id = media_handle_sideload( $file_array, $post_id, $title );
+ if ( is_wp_error( $attachment_id ) ) {
+ wp_delete_file( $temp_file );
+ return $attachment_id;
+ }
+
+ set_post_thumbnail( $post_id, (int) $attachment_id );
+ update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $title ) );
+ update_post_meta( $post_id, self::META_IMAGE_URL, esc_url_raw( $image_url ) );
+
+ return (int) $attachment_id;
+ }
+
+ /**
+ * Get an episode image from iTunes or Media RSS data.
+ *
+ * @param object $item SimplePie item.
+ * @return string Image URL.
+ */
+ private function get_episode_image_url( $item ): string {
+ $image_url = $this->get_tag_attribute( $item, self::ITUNES_NAMESPACE, 'image', 'href' );
+ if ( $image_url ) {
+ return esc_url_raw( $image_url );
+ }
+
+ $image_url = $this->get_tag_attribute( $item, self::MEDIA_NAMESPACE, 'thumbnail', 'url' );
+ if ( $image_url ) {
+ return esc_url_raw( $image_url );
+ }
+
+ return esc_url_raw( $this->get_tag_attribute( $item, self::MEDIA_NAMESPACE, 'content', 'url' ) );
+ }
+
+ /**
+ * Get the first value of a namespaced SimplePie tag.
+ *
+ * @param object $item SimplePie item.
+ * @param string $xml_namespace XML namespace.
+ * @param string $tag Tag name.
+ * @return string Tag value.
+ */
+ private function get_tag_value( $item, string $xml_namespace, string $tag ): string {
+ $tags = $item->get_item_tags( $xml_namespace, $tag );
+ return isset( $tags[0]['data'] ) ? trim( (string) $tags[0]['data'] ) : '';
+ }
+
+ /**
+ * Get the first attribute of a namespaced SimplePie tag.
+ *
+ * @param object $item SimplePie item.
+ * @param string $xml_namespace XML namespace.
+ * @param string $tag Tag name.
+ * @param string $attribute Attribute name.
+ * @return string Attribute value.
+ */
+ private function get_tag_attribute( $item, string $xml_namespace, string $tag, string $attribute ): string {
+ $tags = $item->get_item_tags( $xml_namespace, $tag );
+ return isset( $tags[0]['attribs'][''][ $attribute ] )
+ ? trim( (string) $tags[0]['attribs'][''][ $attribute ] )
+ : '';
+ }
+
+ /**
+ * Normalize an iTunes duration to seconds.
+ *
+ * @param string $duration Duration in seconds, MM:SS, or HH:MM:SS.
+ * @return int Duration in seconds.
+ */
+ private function duration_to_seconds( string $duration ): int {
+ if ( '' === $duration ) {
+ return 0;
+ }
+
+ if ( ctype_digit( $duration ) ) {
+ return absint( $duration );
+ }
+
+ $parts = array_map( 'absint', explode( ':', $duration ) );
+ if ( 2 === count( $parts ) ) {
+ return ( $parts[0] * MINUTE_IN_SECONDS ) + $parts[1];
+ }
+
+ if ( 3 === count( $parts ) ) {
+ return ( $parts[0] * HOUR_IN_SECONDS ) + ( $parts[1] * MINUTE_IN_SECONDS ) + $parts[2];
+ }
+
+ return 0;
+ }
+
+ /**
+ * Sanitize caller-provided import options.
+ *
+ * @param array $options Raw options.
+ * @return array Sanitized options.
+ */
+ private function sanitize_options( array $options ): array {
+ $allowed_statuses = array( 'draft', 'pending', 'private', 'publish' );
+ $post_status = sanitize_key( (string) $options['post_status'] );
+ $post_author = absint( $options['author_id'] );
+ if ( 0 === $post_author ) {
+ $users = get_users(
+ array(
+ 'role__in' => array( 'administrator', 'editor', 'author' ),
+ 'number' => 1,
+ 'orderby' => 'ID',
+ 'order' => 'ASC',
+ 'fields' => 'ids',
+ )
+ );
+ $post_author = isset( $users[0] ) ? absint( $users[0] ) : 0;
+ }
+
+ return array(
+ 'post_status' => in_array( $post_status, $allowed_statuses, true ) ? $post_status : 'publish',
+ 'post_author' => $post_author,
+ 'import_image' => (bool) $options['import_image'],
+ 'update_existing' => (bool) $options['update_existing'],
+ 'item_limit' => absint( $options['item_limit'] ),
+ );
+ }
+
+ /**
+ * Acquire a short-lived per-feed import lock.
+ *
+ * Add_option() provides the atomic operation needed to prevent overlapping
+ * cron and manual imports. Expired locks are recoverable after 30 minutes.
+ *
+ * @param string $lock_key Lock option name.
+ * @return string|false Lock owner token, or false when already locked.
+ */
+ private function acquire_lock( string $lock_key ) {
+ $token = wp_generate_uuid4();
+ $value = array(
+ 'token' => $token,
+ 'expires' => time() + ( 30 * MINUTE_IN_SECONDS ),
+ );
+ if ( $this->create_lock_option( $lock_key, $value ) ) {
+ return $token;
+ }
+
+ $current = get_option( $lock_key, array() );
+ $current_expiry = is_array( $current ) && isset( $current['expires'] ) ? (int) $current['expires'] : 0;
+ if ( $current_expiry && $current_expiry >= time() ) {
+ return false;
+ }
+
+ delete_option( $lock_key );
+ return $this->create_lock_option( $lock_key, $value ) ? $token : false;
+ }
+
+ /**
+ * Atomically create a lock option.
+ *
+ * @param string $lock_key Lock option name.
+ * @param array $value Lock value.
+ * @phpstan-impure
+ */
+ private function create_lock_option( string $lock_key, array $value ): bool {
+ return add_option( $lock_key, $value, '', false );
+ }
+
+ /**
+ * Renew a lock still owned by this process.
+ *
+ * @param string $lock_key Lock option name.
+ * @param string $token Lock owner token.
+ */
+ private function renew_lock( string $lock_key, string $token ): void {
+ $current = get_option( $lock_key, array() );
+ if ( is_array( $current ) && isset( $current['token'] ) && hash_equals( $token, (string) $current['token'] ) ) {
+ $current['expires'] = time() + ( 30 * MINUTE_IN_SECONDS );
+ update_option( $lock_key, $current, false );
+ }
+ }
+
+ /**
+ * Release an import lock.
+ *
+ * @param string $lock_key Lock option name.
+ * @param string $token Lock owner token.
+ * @return void
+ */
+ private function release_lock( string $lock_key, string $token ): void {
+ $current = get_option( $lock_key, array() );
+ if ( is_array( $current ) && isset( $current['token'] ) && hash_equals( $token, (string) $current['token'] ) ) {
+ delete_option( $lock_key );
+ }
+ }
+
+ /**
+ * Create an empty structured import result.
+ *
+ * @param string $feed_url Feed URL.
+ * @return array Empty result.
+ */
+ private function new_result( string $feed_url ): array {
+ return array(
+ 'feed_url' => $feed_url,
+ 'post_type' => $this->post_type,
+ 'total' => 0,
+ 'created' => 0,
+ 'updated' => 0,
+ 'skipped' => 0,
+ 'failed' => 0,
+ 'post_ids' => array(
+ 'created' => array(),
+ 'updated' => array(),
+ 'skipped' => array(),
+ 'failed' => array(),
+ ),
+ 'errors' => array(),
+ 'warnings' => 0,
+ 'has_more' => false,
+ );
+ }
+
+ /**
+ * Convert WP_Error to a serializable result entry.
+ *
+ * @param WP_Error $error WordPress error.
+ * @param string $source_id Optional source identifier.
+ * @param int $post_id Optional post ID.
+ * @param string $severity Error severity.
+ * @return array Serializable error.
+ */
+ private function format_error( WP_Error $error, string $source_id = '', int $post_id = 0, string $severity = 'error' ): array {
+ return array(
+ 'code' => $error->get_error_code(),
+ 'message' => $error->get_error_message(),
+ 'source_id' => $source_id,
+ 'post_id' => $post_id,
+ 'severity' => $severity,
+ );
+ }
+}
diff --git a/src/Options.php b/src/Options.php
new file mode 100644
index 0000000..0604169
--- /dev/null
+++ b/src/Options.php
@@ -0,0 +1,38 @@
+
+ */
+ public static function get(): array {
+ $defaults = array(
+ 'feed_url' => '',
+ 'post_type' => 'podcast_episode',
+ 'post_status' => 'publish',
+ 'author_id' => 0,
+ 'schedule' => 'hourly',
+ 'import_image' => 1,
+ 'update_existing' => 0,
+ 'item_limit' => 25,
+ );
+
+ $value = get_option( self::KEY, array() );
+ return wp_parse_args( is_array( $value ) ? $value : array(), $defaults );
+ }
+}
diff --git a/src/Plugin.php b/src/Plugin.php
new file mode 100644
index 0000000..7408ae8
--- /dev/null
+++ b/src/Plugin.php
@@ -0,0 +1,198 @@
+booted ) {
+ return;
+ }
+
+ $this->booted = true;
+ add_action( 'init', array( $this, 'register_default_post_type' ) );
+ add_action( 'init', array( $this, 'register_import_meta' ), 20 );
+
+ $importer = new Importer();
+ $scheduler = new Scheduler( $importer );
+ $scheduler->register();
+
+ if ( is_admin() ) {
+ $meta_box = new EpisodeMetaBox();
+ $meta_box->register();
+
+ $settings = new Settings( $importer, $scheduler );
+ $settings->register();
+ }
+ }
+
+ /**
+ * Register the bundled fallback episode post type.
+ */
+ public function register_default_post_type(): void {
+ if ( post_type_exists( 'podcast_episode' ) ) {
+ return;
+ }
+
+ register_post_type(
+ 'podcast_episode',
+ array(
+ 'labels' => array(
+ 'name' => __( 'Podcast Episodes', 'rss2cpt' ),
+ 'singular_name' => __( 'Podcast Episode', 'rss2cpt' ),
+ ),
+ 'public' => true,
+ 'show_in_rest' => true,
+ 'has_archive' => true,
+ 'rewrite' => array( 'slug' => 'podcast' ),
+ 'menu_icon' => 'dashicons-microphone',
+ 'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'author', 'custom-fields' ),
+ )
+ );
+ $this->owns_default_post_type = true;
+ }
+
+ /**
+ * Register imported metadata for REST and editor consumers.
+ */
+ public function register_import_meta(): void {
+ $settings = Options::get();
+ $post_type = sanitize_key( (string) $settings['post_type'] );
+ $integers = array(
+ Importer::META_AUDIO_LENGTH,
+ Importer::META_DURATION_SECONDS,
+ Importer::META_SEASON,
+ Importer::META_EPISODE,
+ );
+ $urls = array(
+ Importer::META_FEED_URL,
+ Importer::META_EPISODE_LINK,
+ Importer::META_AUDIO_URL,
+ Importer::META_IMAGE_URL,
+ );
+ $strings = array(
+ Importer::META_SOURCE_ID,
+ Importer::META_AUDIO_TYPE,
+ Importer::META_DURATION,
+ Importer::META_EPISODE_TYPE,
+ Importer::META_EXPLICIT,
+ Importer::META_CREATOR,
+ );
+
+ foreach ( $integers as $meta_key ) {
+ $this->register_meta_key( $post_type, $meta_key, 'integer', 'absint' );
+ }
+ foreach ( $urls as $meta_key ) {
+ $this->register_meta_key( $post_type, $meta_key, 'string', 'esc_url_raw' );
+ }
+ foreach ( $strings as $meta_key ) {
+ $this->register_meta_key( $post_type, $meta_key, 'string', 'sanitize_text_field' );
+ }
+ foreach ( array( Importer::META_SOURCE_KEY, Importer::META_FINGERPRINT ) as $meta_key ) {
+ $this->register_meta_key( $post_type, $meta_key, 'string', 'sanitize_text_field', false );
+ }
+ }
+
+ /**
+ * Register one protected episode meta key.
+ *
+ * @param string $post_type Post type name.
+ * @param string $meta_key Metadata key.
+ * @param string $type REST scalar type.
+ * @param callable $sanitize Sanitization callback.
+ * @param bool $show_rest Whether to expose the field through REST.
+ */
+ private function register_meta_key( string $post_type, string $meta_key, string $type, callable $sanitize, bool $show_rest = true ): void {
+ register_post_meta(
+ $post_type,
+ $meta_key,
+ array(
+ 'type' => $type,
+ 'single' => true,
+ 'show_in_rest' => $show_rest,
+ 'sanitize_callback' => $sanitize,
+ 'auth_callback' => array( __CLASS__, 'authorize_meta_change' ),
+ )
+ );
+ }
+
+ /**
+ * Authorize writes to protected episode metadata.
+ *
+ * @param bool $allowed Default decision.
+ * @param string $meta_key Metadata key.
+ * @param int $post_id Post ID.
+ */
+ public static function authorize_meta_change( $allowed, $meta_key, $post_id ): bool {
+ unset( $allowed, $meta_key );
+ return current_user_can( 'edit_post', (int) $post_id );
+ }
+
+ /**
+ * Run plugin activation tasks.
+ */
+ public static function activate(): void {
+ self::instance()->register_default_post_type();
+ flush_rewrite_rules();
+ }
+
+ /**
+ * Run plugin deactivation tasks.
+ */
+ public static function deactivate(): void {
+ Scheduler::clear();
+ $plugin = self::instance();
+ if ( $plugin->owns_default_post_type && post_type_exists( 'podcast_episode' ) ) {
+ unregister_post_type( 'podcast_episode' );
+ }
+ flush_rewrite_rules();
+ }
+}
diff --git a/src/Scheduler.php b/src/Scheduler.php
new file mode 100644
index 0000000..f644274
--- /dev/null
+++ b/src/Scheduler.php
@@ -0,0 +1,129 @@
+importer = $importer;
+ }
+
+ /**
+ * Register cron and settings hooks.
+ */
+ public function register(): void {
+ add_action( self::HOOK, array( $this, 'run' ) );
+ add_action( self::CONTINUATION_HOOK, array( $this, 'run' ) );
+ add_action( 'update_option_' . Options::KEY, array( $this, 'settings_updated' ), 10, 2 );
+ $this->ensure_scheduled();
+ }
+
+ /**
+ * Run a scheduled import.
+ */
+ public function run(): void {
+ $result = $this->importer->import( Options::get() );
+ $this->record_result( $result );
+
+ if ( is_array( $result ) && ! empty( $result['has_more'] ) ) {
+ $this->schedule_continuation();
+ }
+ }
+
+ /**
+ * Schedule the next bounded backfill batch.
+ */
+ public function schedule_continuation(): void {
+ $settings = Options::get();
+ if ( 'disabled' !== $settings['schedule'] && ! wp_next_scheduled( self::CONTINUATION_HOOK ) ) {
+ wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::CONTINUATION_HOOK );
+ }
+ }
+
+ /**
+ * Reschedule after a settings change.
+ *
+ * @param mixed $old_value Previous option value.
+ * @param mixed $new_value New option value.
+ */
+ public function settings_updated( $old_value, $new_value ): void {
+ unset( $old_value, $new_value );
+ self::clear();
+ $this->ensure_scheduled();
+ }
+
+ /**
+ * Ensure the configured recurring event exists.
+ */
+ public function ensure_scheduled(): void {
+ $settings = Options::get();
+ $schedule = (string) $settings['schedule'];
+
+ if ( empty( $settings['feed_url'] ) || 'disabled' === $schedule || wp_next_scheduled( self::HOOK ) ) {
+ return;
+ }
+
+ $available = wp_get_schedules();
+ if ( isset( $available[ $schedule ] ) ) {
+ wp_schedule_event( time() + MINUTE_IN_SECONDS, $schedule, self::HOOK );
+ }
+ }
+
+ /**
+ * Clear all scheduled imports.
+ */
+ public static function clear(): void {
+ wp_clear_scheduled_hook( self::HOOK );
+ wp_clear_scheduled_hook( self::CONTINUATION_HOOK );
+ }
+
+ /**
+ * Persist a bounded last-run summary for support and diagnostics.
+ *
+ * @param array|\WP_Error $result Import result.
+ */
+ public function record_result( $result ): void {
+ $summary = array(
+ 'timestamp' => time(),
+ 'success' => ! is_wp_error( $result ),
+ );
+
+ if ( is_wp_error( $result ) ) {
+ $summary['error_code'] = sanitize_key( $result->get_error_code() );
+ $summary['message'] = sanitize_text_field( wp_trim_words( $result->get_error_message(), 30 ) );
+ } else {
+ foreach ( array( 'created', 'updated', 'skipped', 'failed', 'warnings' ) as $key ) {
+ $summary[ $key ] = isset( $result[ $key ] ) ? absint( $result[ $key ] ) : 0;
+ }
+ }
+
+ update_option( 'rss2cpt_last_import', $summary, false );
+ }
+}
diff --git a/tests/SmokeTest.php b/tests/SmokeTest.php
new file mode 100644
index 0000000..7619cbd
--- /dev/null
+++ b/tests/SmokeTest.php
@@ -0,0 +1,43 @@
+assertTrue( version_compare( PHP_VERSION, '7.4', '>=' ) );
+ }
+
+ /**
+ * Verify the persisted option name remains stable.
+ */
+ public function test_option_key_is_stable(): void {
+ $this->assertSame( 'rss2cpt_settings', RSS2CPT\Options::KEY );
+ }
+
+ /**
+ * Verify backfills use a safe bounded default.
+ */
+ public function test_default_import_limit_is_bounded(): void {
+ $settings = RSS2CPT\Options::get();
+ $this->assertSame( 25, $settings['item_limit'] );
+ }
+
+ /**
+ * Verify ongoing imports do not inherit WordPress's long feed cache.
+ */
+ public function test_importer_uses_short_feed_cache(): void {
+ $importer = new RSS2CPT\Import\Importer();
+ $this->assertSame( 300, $importer->filter_feed_cache_lifetime( 43200, 'https://example.test/feed.xml' ) );
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..e6b8cb5
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,35 @@
+