Skip to content

Support Castos campaigns via passthrough URLs#899

Merged
zahardev merged 2 commits intodevelopfrom
version/3.16.0-campaigns
May 7, 2026
Merged

Support Castos campaigns via passthrough URLs#899
zahardev merged 2 commits intodevelopfrom
version/3.16.0-campaigns

Conversation

@zahardev
Copy link
Copy Markdown
Collaborator

@zahardev zahardev commented May 6, 2026

Summary

  • Generalize the ads-only passthrough mechanism to support Castos campaigns
  • Rename Ads_Controller to Passthrough_Controller — activates passthrough when either ads or campaigns are enabled
  • Add PUT /ssp/v1/podcasts/{series_id} endpoint for Castos to push ads/campaigns status changes immediately (HMAC-authenticated)
  • Hourly cron sync serves as backup, replacing the old daily ads-only check
  • Remove user-facing "Enable Castos Ads" checkbox — Castos is the single source of truth
  • Flush episode file data transients when passthrough status changes via the API
  • Unschedule legacy ssp_check_ads cron hook on upgrade

Castos-side changes required

  • Add campaigns_enabled field to GET /api/v2/podcasts response
  • Add campaigns_enabled field to GET /api/v2/ssp/episodes/{id}/file response (in the podcast object)
  • Call PUT {site_url}/wp-json/ssp/v1/podcasts/{series_id} with HMAC auth when ads or campaigns settings change

Test plan

  • Verify passthrough URLs activate when enable_campaigns option is set to on
  • Verify passthrough URLs still activate for ads-only (no regression)
  • Verify PUT /ssp/v1/podcasts/{series_id} updates local options and flushes transients
  • Verify HMAC auth rejects unsigned requests to the new endpoint
  • Verify old ssp_check_ads cron hook gets unscheduled after upgrade
  • Run SSPEpisodeFunctionsTest — all 27 tests should pass

Summary by CodeRabbit

  • New Features

    • Passthrough content management for dynamic episode enclosure handling
    • Campaigns support added alongside ads
    • New REST endpoint to update podcast passthrough settings
  • Chores

    • Removed legacy ad scheduling and related controller initialization
  • Tests

    • Added unit tests validating passthrough behavior across ads/campaigns configurations

Enable passthrough URLs for podcasts with active campaigns, not just ads.
Castos can push status changes via PUT /ssp/v1/podcasts/{series_id} for
immediate activation; hourly cron sync serves as backup.

Co-Authored-By: Claude via AIContext
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

📝 Walkthrough

Walkthrough

Replaces Ads_Controller with a new Passthrough_Controller to manage Castos dynamic passthrough content (ads and campaigns), adds campaigns flags to entities and REST payloads, exposes a REST PUT to update per-series passthrough settings and flush related transients, removes legacy ads cron scheduling, and adds unit tests for passthrough behavior.

Changes

Passthrough and Campaigns Integration

Layer / File(s) Summary
Data Shape
php/classes/entities/class-api-podcast.php, php/classes/entities/class-episode-file-data.php
Added campaigns_enabled boolean to API_Podcast and Episode_File_Data; Episode_File_Data constructor initializes from podcast.campaigns_enabled.
Core Implementation
php/classes/controllers/class-passthrough-controller.php
New Passthrough_Controller class: constructor registers enclosure URL filter and cron hook; implements maybe_use_dynamic_content(), sync_passthrough_settings(), protected get_episode_file_data() with transient caching, and sync_series_option() to align local options with Castos.
App Wiring
php/classes/controllers/class-app-controller.php
Replaced ads_controller property with passthrough_controller; bootstrap now instantiates Passthrough_Controller and initializes Review_Controller where Ads_Controller was previously created.
Cron Migration
php/classes/controllers/class-cron-controller.php
schedule_events() now unschedules legacy ssp_check_ads cron job instead of scheduling it.
REST Integration
php/classes/rest/class-episodes-rest-controller.php, php/classes/rest/class-rest-api-controller.php
Added PUT /podcasts/(?P<series_id>[\d]+) route and update_podcast($request) to set ads_enabled and campaigns_enabled per series (via ssp_update_option) and call flush_episode_file_data_transients() which deletes ssp_episode_file_data_<id> transients. get_default_podcast_settings() now includes campaigns_enabled.
Passthrough Logic
php/includes/ssp-functions.php
ssp_series_passthrough_required() now returns true if ads OR campaigns are enabled for the series (preserving existing Stats-plugin filter behavior).
Tests
tests/WPUnit/SSPEpisodeFunctionsTest.php
Added four unit tests covering series- and episode-level passthrough behavior for ads and campaigns flags and negative case; tests set and clean options accordingly.

Sequence Diagram

sequenceDiagram
    actor Client
    participant REST as "REST API"
    participant PassthruCtrl as "Passthrough_Controller"
    participant WordPress as "WordPress"
    participant CastosAPI as "Castos API"

    Client->>REST: PUT /podcasts/{series_id} (ads_enabled, campaigns_enabled)
    REST->>PassthruCtrl: update_podcast(request)
    PassthruCtrl->>WordPress: ssp_update_option(enable_ads/enable_campaigns)
    REST->>REST: flush_episode_file_data_transients(series_id)
    REST-->>Client: 200 OK {series_id}

    WordPress->>PassthruCtrl: maybe_use_dynamic_content(enclosure_url, episode_id)
    PassthruCtrl->>PassthruCtrl: ssp_series_passthrough_required(series_id)
    alt passthrough required
        PassthruCtrl->>PassthruCtrl: get_episode_file_data(episode_id) (check transient)
        alt cache miss
            PassthruCtrl->>CastosAPI: fetch episode file data
            CastosAPI-->>PassthruCtrl: episode file data (dynamic_url, flags)
            PassthruCtrl->>WordPress: set transient(episode_file_data)
        end
        PassthruCtrl-->>WordPress: return dynamic_url
    else passthrough not required
        PassthruCtrl-->>WordPress: return original_url
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hopped through code on a sunny sprint day,
Castos brought passthrough to dance and to play,
Ads stepped aside, campaigns took the stage,
Transients refreshed, tests wrote the page —
Hop, cache, deploy; carrots for the display! 🍽️🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: generalizing the passthrough mechanism to support both Castos campaigns and ads, replacing the previous ads-only approach.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch version/3.16.0-campaigns

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@php/classes/controllers/class-cron-controller.php`:
- Around line 113-116: The unschedule logic for the legacy cron
('ssp_check_ads') currently runs on admin_init and therefore misses auto-upgrade
installs; move this logic into the plugin upgrade/migration path so it runs
during upgrades (e.g., inside your upgrade handler/migration method that runs
when plugin version changes or during activation update flow). Specifically,
remove the wp_next_scheduled/wp_unschedule_event block from admin_init and
invoke the same checks (call wp_next_scheduled('ssp_check_ads') and
wp_unschedule_event($timestamp, 'ssp_check_ads') when $timestamp is truthy) from
the upgrade routine (the function/method responsible for version migrations),
ensuring it runs even on sites without admin page visits.

In `@php/classes/controllers/class-passthrough-controller.php`:
- Around line 61-63: The hourly cron path currently toggles series flags via
sync_series_option (called with self::ENABLE_ADS_OPTION and
self::ENABLE_CAMPAIGNS_OPTION) but does not clear the stale
ssp_episode_file_data_* transients, so add logic immediately after those
sync_series_option calls to purge related transients for that series; implement
a private helper on the PassthroughController class (e.g.
clear_episode_file_data_transients($series_id)) that removes all matching
ssp_episode_file_data_* transients for the given series (use the appropriate
WordPress transient/option deletion approach or a $wpdb DELETE LIKE on
_transient/_transient_timeout) and call it from the cron sync path so the cron
fallback matches the PUT webhook behavior.

In `@php/classes/rest/class-episodes-rest-controller.php`:
- Around line 136-143: The route uses update_item_permissions_check() which
delegates to validate_castos_authentication() and currently returns distinct 401
bodies; change the auth error handling so any authentication failure from
validate_castos_authentication() (and any path inside
update_item_permissions_check()) is normalized to a single generic WP_Error with
status 401 and a generic message like "Request signature invalid." — locate
validate_castos_authentication() and/or update_item_permissions_check() in
class-episodes-rest-controller.php and replace or wrap all branches that return
WP_Error for missing/invalid headers or keys so they instead return the same
generic WP_Error object (keep the unique function names:
validate_castos_authentication, update_item_permissions_check, update_podcast)
to ensure the PUT /podcasts/(?P<series_id>[\d]+) route always emits the
identical 401 response body.
- Around line 324-345: The transient flush misses episodes that have no series
term (the "default" series) and non-published posts because get_posts() defaults
to published posts; in flush_episode_file_data_transients($series_id) change the
query args to include 'post_status' => 'any' and, when $series_id is
empty/represents the default series, replace the tax_query that selects by
'terms' with a tax_query that uses ssp_series_taxonomy() and 'operator' => 'NOT
EXISTS' so you fetch posts without that taxonomy term; otherwise keep the
existing tax_query for a specific $series_id. Ensure you still iterate over
$episode_ids and delete_transient('ssp_episode_file_data_' . $episode_id) as
before.

In `@php/classes/rest/class-rest-api-controller.php`:
- Line 81: The REST series response is using the global option key
('enable_campaigns') while the series field plumbing expects per-series meta
('ss_podcasting_data_campaigns_enabled_{series_id}'), so align the read/write
keys: change the $podcast['campaigns_enabled'] assignment in
class-rest-api-controller.php to obtain the value via the same per-series
accessor used by create_api_series_fields()/series_get_field_value(), and update
the PUT/update handler that currently calls
ssp_update_option('enable_campaigns') to persist to the per-series meta key (the
ss_podcasting_data_campaigns_enabled_{series_id} key or the helper that writes
series meta) so reads and writes use the same identifier.

In `@tests/WPUnit/SSPEpisodeFunctionsTest.php`:
- Around line 365-372: The test mutates shared option state with
ssp_update_option and currently only resets it after assertions, risking
leftover state when assertions fail; update tests like
testSeriesPassthroughRequiredWithAdsEnabled (and the analogous campaigns-enabled
and episode-level tests) to wrap the mutation and assertion in a try/finally
block where you call ssp_update_option to enable the flag, run the
ssp_series_passthrough_required / related assertion inside try, and in finally
reset both passthrough-related options (both series and campaign/episode
passthrough flags) via ssp_update_option to their original values so cleanup
always runs even on failure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: dcb943a3-dee4-4012-9cd8-5aec3b316b49

📥 Commits

Reviewing files that changed from the base of the PR and between 75da8f2 and d283f79.

📒 Files selected for processing (10)
  • php/classes/controllers/class-ads-controller.php
  • php/classes/controllers/class-app-controller.php
  • php/classes/controllers/class-cron-controller.php
  • php/classes/controllers/class-passthrough-controller.php
  • php/classes/entities/class-api-podcast.php
  • php/classes/entities/class-episode-file-data.php
  • php/classes/rest/class-episodes-rest-controller.php
  • php/classes/rest/class-rest-api-controller.php
  • php/includes/ssp-functions.php
  • tests/WPUnit/SSPEpisodeFunctionsTest.php
💤 Files with no reviewable changes (1)
  • php/classes/controllers/class-ads-controller.php

Comment thread php/classes/controllers/class-cron-controller.php
Comment thread php/classes/controllers/class-passthrough-controller.php
Comment thread php/classes/rest/class-episodes-rest-controller.php
Comment thread php/classes/rest/class-episodes-rest-controller.php
Comment thread php/classes/rest/class-rest-api-controller.php
Comment thread tests/WPUnit/SSPEpisodeFunctionsTest.php
Co-Authored-By: Claude via AIContext
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
php/classes/rest/class-episodes-rest-controller.php (2)

324-347: 💤 Low value

Default-series flush is overly broad but safe.

When $series_id is 0 (default series), the method flushes transients for all episodes across all series rather than just episodes without a series term. This is more conservative (no stale data) but less efficient.

If precision is preferred, filter to episodes lacking the series taxonomy:

♻️ Targeted flush for default series
     if ( $series_id ) {
         $args['tax_query'] = array(
             array(
                 'taxonomy' => ssp_series_taxonomy(),
                 'field'    => 'term_id',
                 'terms'    => $series_id,
             ),
         );
+    } else {
+        // Default series: episodes with no series term assigned
+        $args['tax_query'] = array(
+            array(
+                'taxonomy' => ssp_series_taxonomy(),
+                'operator' => 'NOT EXISTS',
+            ),
+        );
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@php/classes/rest/class-episodes-rest-controller.php` around lines 324 - 347,
The flush_episode_file_data_transients function currently clears transients for
all episodes when $series_id is 0; instead, modify the conditional so when
$series_id is truthy you keep the existing tax_query filtering by term_id, and
when $series_id is 0 you add a tax_query for the series taxonomy using operator
'NOT EXISTS' (or equivalent) to target only episodes that lack a series term;
update the block referencing ssp_series_taxonomy(), get_posts(), and
delete_transient('ssp_episode_file_data_' . $episode_id) accordingly so only
episodes without the series term are flushed in the default-series case.

296-316: 💤 Low value

Consider validating that series_id corresponds to an existing series term.

The endpoint accepts any integer series_id without verifying the term exists. This could lead to orphan options being created for non-existent series. While not critical (the data is harmless), validation would improve API correctness.

♻️ Suggested validation
 public function update_podcast( $request ) {
     $series_id         = (int) $request->get_param( 'series_id' );
+
+    // Validate series exists (0 represents default series)
+    if ( $series_id && ! term_exists( $series_id, ssp_series_taxonomy() ) ) {
+        return new \WP_Error( 'invalid_series', 'Series not found.', array( 'status' => 404 ) );
+    }
+
     $ads_enabled       = $request->get_param( 'ads_enabled' );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@php/classes/rest/class-episodes-rest-controller.php` around lines 296 - 316,
The update_podcast handler currently accepts any integer series_id; before
calling ssp_update_option or flush_episode_file_data_transients, validate the
series term exists (use term_exists($series_id, 'series') or
get_term($series_id, 'series')) and if it does not, return a REST error (e.g.
new WP_Error('rest_series_not_found', 'Series not found', array('status' =>
404)) or equivalent rest_ensure_response) instead of proceeding; place this
check at the start of update_podcast to prevent creating options for
non-existent series.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@php/classes/rest/class-episodes-rest-controller.php`:
- Around line 324-347: The flush_episode_file_data_transients function currently
clears transients for all episodes when $series_id is 0; instead, modify the
conditional so when $series_id is truthy you keep the existing tax_query
filtering by term_id, and when $series_id is 0 you add a tax_query for the
series taxonomy using operator 'NOT EXISTS' (or equivalent) to target only
episodes that lack a series term; update the block referencing
ssp_series_taxonomy(), get_posts(), and
delete_transient('ssp_episode_file_data_' . $episode_id) accordingly so only
episodes without the series term are flushed in the default-series case.
- Around line 296-316: The update_podcast handler currently accepts any integer
series_id; before calling ssp_update_option or
flush_episode_file_data_transients, validate the series term exists (use
term_exists($series_id, 'series') or get_term($series_id, 'series')) and if it
does not, return a REST error (e.g. new WP_Error('rest_series_not_found',
'Series not found', array('status' => 404)) or equivalent rest_ensure_response)
instead of proceeding; place this check at the start of update_podcast to
prevent creating options for non-existent series.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fbd4ad78-4e5f-4754-be30-0ace3cd9b106

📥 Commits

Reviewing files that changed from the base of the PR and between d283f79 and cdef3a2.

📒 Files selected for processing (1)
  • php/classes/rest/class-episodes-rest-controller.php

@zahardev zahardev merged commit fdf4115 into develop May 7, 2026
2 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request May 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant