fix(podcasts): generic media delete refuses episode rows (invariant 2)

The admin endpoint DELETE /api/media-items/:id predated podcasts and
would delete any media row — including podcast episodes — if hit
directly. Invariant 2 (absolute form, user decision 2026-10-30) says
episode rows are removed ONLY by whole-feed removal: "the feed should
match the XML until the entire feed is gone." The plan audit found
this endpoint was the one remaining surface that could violate it:
the poller, unsubscribe, and the admin file-deletion endpoints are
files-only, and the hash-conflict merge can't see episodes (they
never hash).

The guard sits right after the GetMediaItem read and BEFORE the
storage-down check, so a refused episode never touches storage
resolution. Same segregation pattern as the generic edit path
(D9/D10, UpdateMediaItem).

Pinned by TestDeleteMediaItemPodcastGuard: admin DELETE of an episode
→ 409 with the row surviving; a book delete through the same endpoint
→ 204 control.
This commit is contained in:
John O'Keefe committed 2026-10-30 14:57:08 -04:00
1 parent 5fee8bfb4a
commit f47734dd7d
2 files changed
+59

No files matched your search

+11
View File
@@ -1881,6 +1881,17 @@ func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Invariant 2 (podcast plan §10, absolute form): episode rows are
// removed ONLY when the whole feed is removed — the feed must match
// its XML until then. Same segregation as the edit path (D9/D10):
// episodes are fetcher-owned; delete FILES via the podcast surfaces
// (feed page / admin podcast view) instead.
if media.Source == "podcast_episode" {
return c.JSON(http.StatusConflict, map[string]string{
"error": "podcast episodes are removed only by removing the whole feed — delete the episode's files instead (feed page or admin podcast view)",
})
}
// Storage-down guard (see HandleBulkDelete): refuse while the library
// storage is unreachable so the cascade can't destroy annotations for
// a file that will re-import on the next scan.