Skip to content

Castopod Feed Empty-GUID Backfill: Natively-Uploaded Episodes Silently Collide in Strict RSS Clients

🛑 Problem

A listener reports missing episodes — a podcast app (gPodder confirmed; likely others with strict RSS parsing) silently drops most of a feed's recent episodes, showing only one where several should appear. Castopod's own admin UI shows every episode present and published; nothing looks wrong from inside Castopod.

🔬 Root Cause

Castopod 2.0.0-next.4 does not mint a <guid> value for episodes created through the native admin-upload path. The cp_episodes.guid database column is left NULL or empty, which renders as <guid></guid> (or omits the element) in the published RSS feed.

RSS/podcast clients that strictly follow the spec treat two items with the same GUID — including two items that both have an empty GUID — as the same episode. An empty string is a valid (if useless) GUID value, and every empty-GUID episode collides with every other one. The client keeps the first it sees and silently discards the rest. No error, no warning — the episodes are just gone from the listener's app.

Why imports are unaffected: episodes bulk-imported from an existing RSS feed (e.g. during a migration from Anchor, Buzzsprout, etc.) retain their original GUIDs from the source feed. The bug is specific to episodes created through Castopod's own admin UI after that point — the import path and the native-creation path go through different code, and only the native path fails to generate one.

Where it lives in the code: app/Entities/Episode.php, method setGuid(). As of 2.0.0-next.4:

public function setGuid(?string $guid = null): static
{
    if ($guid !== null && $guid !== '') {
        $this->attributes['guid'] = $guid;
    } elseif ($this->link !== '') {
        $this->attributes['guid'] = $this->link;
    }
    // ...
}

If no explicit $guid is passed and $this->link is empty at the point setGuid() runs, the guid attribute is never set at all. At native-episode-creation time, $this->link is not yet populated, so both branches fail silently — no exception, just a column that stays empty.

🩹 Fix Applied

This is a mitigation, not a permanent code fix. setGuid()'s fallback logic above is unpatched as of this writing — the underlying bug is still present in the installed version. What was done:

1. Diagnose the scope (identify every episode with an empty GUID):

SELECT id, guid, created_at FROM cp_episodes
WHERE podcast_id = 1 AND (guid IS NULL OR guid = '');

2. Back up before writing:

mysqldump ... --where='podcast_id=1 AND (guid IS NULL OR guid="")' \
  > cp_episodes_guid_backfill_pre_<date>.sql

3. Backfill in a transaction:

UPDATE cp_episodes SET guid = UUID(), updated_at = NOW()
WHERE podcast_id = 1 AND (guid IS NULL OR guid = '');

4. Clear the feed cache (Castopod caches the rendered RSS; a stale cache will keep serving the old empty-GUID XML after the DB fix):

php spark cache:clear

5. Verify live: fetch the feed URL directly and confirm every <guid> element is present and unique — don't just trust the DB update count, the cache-clear step is easy to forget and produces a fix that looks complete but isn't.

⚠️ Recurrence and the belt-and-suspenders option

Because the root cause in setGuid() is not patched, every episode created through the admin UI is a candidate for this bug until the code is actually fixed upstream or patched locally. Two ways to guard against recurrence without touching PHP:

  • Re-run the backfill SQL after every new upload (steps 3–4 above) — works, but depends on remembering to do it every time.
  • A BEFORE INSERT trigger on cp_episodes, as a permanent safety net:
    DELIMITER $$
    CREATE TRIGGER cp_episodes_guid_fallback BEFORE INSERT ON cp_episodes
    FOR EACH ROW
    BEGIN
      IF NEW.guid IS NULL OR NEW.guid = '' THEN
        SET NEW.guid = UUID();
      END IF;
    END$$
    DELIMITER ;
    
    This doesn't fix the application code, but it makes the empty-GUID state structurally impossible regardless of what path created the row.

A real code fix would set the GUID at the point of episode creation — either inside setGuid()'s fallback branch (when both $guid and $this->link are empty, generate a UUID instead of leaving the attribute unset) or via a beforeInsert model callback in EpisodeModel.php. This is also the shape of a fix worth reporting upstream (see castopod/castopod issue trackers for adjacent reports on GUID generation, e.g. GUID-set-to-permalink and GUID-on-creation issues) — pre-2.0.0 versioning means it's realistically fixable before a stable release ships.

Status as observed 2026-09-25: no empty GUIDs found across the full current episode set on the instance this was diagnosed on, and recent episodes carry client-generated-looking UUIDs rather than the MySQL UUID() format the manual backfill produces. The exact mechanism keeping new episodes clean was not conclusively identified during this write-up — setGuid()'s code is unchanged, so either the calling code now supplies a GUID before setGuid() is invoked, or the backfill/trigger mitigation has continued being applied. Worth re-confirming directly (check for the trigger, or diff Episode.php against the version quoted above) rather than assuming either explanation.

📚 References

  • Castopod source: app/Entities/Episode.php (setGuid()), app/Models/EpisodeModel.php
  • RSS 2.0 spec — <guid>: "if the guid element is not permalink, it must uniquely identify the item" — an empty string trivially fails this.
  • Self-Hosting a Podcast with Castopod
  • 30-Areas/MajorInfrastructure/Migrations/Anchor-to-Castopod-Migration.md — the migration this was found during
  • 30-Areas/MajorInfrastructure/Servers/tttpod.md § Incident Log, 2026-05-07 — the original incident record