Configuration & Experiments

September 21, 2026 ยท View on GitHub

< Docs index | Project README


Contents

Configuration

All configuration is in the web UI or REST API. No config files needed.

Adding Feeds

Add a feed from the dashboard at /ui/ (Add Feed, RSS URL, optional custom slug) or with POST /api/v1/feeds (see API & Webhooks).

Global feed defaults

Settings > Global Defaults controls verification and cross-fetch differential. Verification can run or be skipped. Cross-fetch can run automatically for feeds that look DAI-served, always run, or stay off. Settings > Transcripts & Chapters controls chapter mode: preserve upstream chapters automatically, always generate chapters, or turn chapters off. During normal processing, the mode is applied only when Generate Chapters is on.

New feeds use each global choice. A feed set to Inherit continues using that choice; an explicit Feed Settings value overrides it. The same controls are available through PUT /api/v1/settings/ad-detection and PATCH /api/v1/feeds/{slug}; see the OpenAPI specification for fields and accepted values.

Upgrades preserve existing feed choices instead of switching them to inheritance. The earlier cross-fetch enabled flag becomes an explicit On or Off, while an unset legacy flag becomes explicit Auto.

Ad Detection Settings

Customize ad detection in Settings:

  • LLM Provider - Switch between Anthropic (direct API), OpenRouter, Ollama (local), or OpenAI-compatible endpoints at runtime without restarting the container
  • AI Model - Model for first pass ad detection. A provider selector sits beside it; leave it on Default to use the LLM Provider above, or pick a different configured provider to run detection there instead
  • Verification Model - Separate model for the post-cut verification pass. Its provider selector defaults to Same as detection and can be pointed at any other configured provider
  • Chapters Model - Model for chapter generation (a small model like Haiku works well here). Its provider selector also defaults to Same as detection
  • Ad chapters - Publish segments left in the audio as their own chapters, so a chapter-aware player can skip them. Off by default. See Podcasting 2.0 > Ad chapters
  • Audio Bitrate - Output bitrate for processed audio (default 128k)
  • System Prompts - Customizable prompts for first pass and verification detection
  • Ad break filler gap threshold - ads in the same break separated by less than this many seconds of speech are merged into one cut. Default 12 seconds. Set to 0 to disable. Merges that would exceed 5 minutes total are skipped. See Nearby-Ad Merge
  • LLM Tunables - See below

Each customizable prompt (first pass system, verification, chapter, and the Ad Reviewer's review and resurrect prompts under AI & Processing) has its own Reset button next to its label, in addition to the section-wide "Reset Prompts to Default" / "Reset Reviewer Prompts to Default" buttons. The per-prompt button is a two-click confirm; it stays visible but disabled (with a tooltip) while that prompt is already at its default, so a customized prompt is easy to spot and revert without resetting every prompt at once.

Seed sponsors

Four toggles decide which LLM passes are handed the running list of known sponsors: Detection, Verification, Reviewer, and Resurrect. Turning one off does not turn off that pass; it just stops seeding its prompt with prior sponsors, so the pass judges each candidate on its own. Turning off Reviewer, for example, makes that pass an independent second opinion rather than a check that already expects the sponsor it is reviewing.

All four default on to match prior behavior. API: PUT /api/v1/settings/ad-detection with seedSponsorsDetection, seedSponsorsVerification, seedSponsorsReviewer, seedSponsorsResurrect (booleans).

Ad Reviewer

The ad reviewer is an opt-in third LLM stage that sits between detection and audio cutting. After pass 1 detection (and again after pass 2), the reviewer takes each candidate ad along with 60 seconds of transcript on either side and decides one of three things: confirm the detection as is, adjust the start or end timestamps within a configured cap, or reject the segment as a false positive. The reviewer also gets a second look at validator-rejected detections whose confidence sits within 20 percentage points of your min_cut_confidence slider, and may resurrect them as real ads.

When to enable it:

  • Comedy and fiction podcasts that include in-bit fake sponsor reads (Welcome to Night Vale was the torture test for this feature)
  • News shows that read sponsor-adjacent copy editorially without it actually being an ad break
  • Hosts who organically mention their own other shows or Patreon, where the detector flags a non-ad as promotional
  • Episodes where you have noticed the cut is starting a few seconds late or ending a few seconds early

Cost is one extra LLM call per detected ad (and one extra call per rejected detection in the resurrection band). With a typical pass-1 model and a typical episode that produces 4 to 8 ad detections, expect a small percentage increase in per-episode token spend rather than a doubling.

Settings live under AI & Processing -> Ad Reviewer:

  • Enable ad reviewer - master toggle, off by default
  • Review provider - Same as pass (default) runs the reviewer on whichever provider and model resolved the pass it is reviewing, and ignores the review model setting below. Pick Primary or Secondary to run the reviewer on that slot instead, such as detection on primary with review on a cheaper secondary account
  • Review model - only used when Review provider is not Same as pass. Same as pass model reuses the pass-1 detection model on pass-1 review and the verification model on pass-2 review. You can override to a single specific model for both reviewer passes (for example, run pass-1 detection on a smaller cheap model and run reviewer on a larger model that is better at boundary work)
  • Max boundary shift - caps how far the reviewer can move start or end timestamps when it chooses adjust. Default 60 seconds. Enforced in code regardless of what the prompt says
  • Review prompt - system prompt for the confirm/adjust/reject reviewer
  • Resurrect prompt - system prompt for the resurrect/reject reviewer over rejected detections

Reviewer activity surfaces in two places:

  • The episode detail page shows the original timestamps on top and a Reviewer: MM:SS - MM:SS line beneath when the reviewer adjusted boundaries. Reviewer-rejected ads carry a Source: Reviewer tag in the rejected detections list.
  • The Stats page shows an Ad Reviewer Stats card with verdict counts (confirmed, adjusted, rejected, resurrected, failed), pass-1 and pass-2 adjustment counts, average boundary shift in seconds, and resurrection count. The card hides when the reviewer has not run.

Text recurrence hints

Settings > AI & Processing has a Text recurrence hints toggle. When on, MinusPod compares the current transcript against a show's last two or more processed episodes and flags spans of wording that repeat near-verbatim, such as intros, credits, and other boilerplate. Those spans go to pass 1 detection as a hint; nothing is ever cut on text recurrence alone.

Off by default. API: PUT /api/v1/settings/ad-detection with textRecurrenceHints (boolean).

Detection Tuning

Settings > Ad Detection has two grouped subsections for tuning how aggressively the verification pass and the cross-fetch differential stage act on what they find. All six controls are database settings; API: PUT /api/v1/settings/ad-detection (see openapi.yaml).

Verification pass - governs standalone catches: ads pass 2 finds that pass 1 missed and that overlap no pass-1 marker.

ControlDefaultRangeNotes
Hold floor0.600.0 - 1.0Confidence a standalone verification catch must reach to hold for review. Below it, the catch is dropped and logged instead of surfacing.
Autocutoff (0)0.5 - 1.0, or offWhen enabled, cuts a standalone catch automatically once it reaches this confidence, instead of holding it for review. Off by default, so catches only ever hold or drop.
Pattern-learning floor0.850.5 - 1.0Minimum confidence before a detection can teach the pattern matcher a new sponsor. Applies to ads up to 90 seconds.
Pattern-learning floor, long ads0.920.5 - 1.0Same floor for ads longer than 90 seconds. Higher by default, since a long span is costlier to learn wrong.
Learning minimum length15s1 - 600sBelow this, a detection is usually a fragment or a passing mention rather than an ad, so nothing is learned from it.
Learning maximum length120s1 - 1800sA longer detection is split at its ad transitions and each read is learned separately. Raise this for feeds whose ad blocks run long.

A held standalone catch carries a verification_miss hold reason and shows a "Verification catch" chip in the Held for Review section; it gets the same waveform editor and approve/dismiss flow as any other held ad. See Held for Review and Verification Pass.

Differential detection - governs the cross-fetch stage's candidate and hold gates.

ControlDefaultRangeNotes
Correlation ceiling0.600.0 - 1.0A cross-fetch region becomes a differential candidate only when its measured correlation is at or below this value. A higher correlation means the two fetches matched too closely to be a real ad swap.
Hold minimum length10s0 - 120sAn uncorroborated differential candidate shorter than this is dropped instead of held for review. Set to 0 to hold a candidate of any length.

Raise the correlation ceiling if genuine ad swaps are being missed as alignment noise, or lower it if identical-content regions are surfacing as false differential candidates. Raise the hold minimum length if short re-roll noise is showing up as holds; lower it (or disable it) if a feed's shortest DAI fills are being dropped before you get a chance to review them. See Cross-Fetch Differential for how these gates fit into the stage, including how audio cue templates corroborate candidates independently of both settings.

Opening window exclusion

adDetectionExcludeStartSeconds (Settings > Ad Detection, 0 to 600 s, default 0) ignores any ad marker that begins inside the first N seconds of an episode, so a show's opening cue is not mistaken for an ad break. The exclusion applies to the first pass, cue pairs, and the verification pass. Each feed can inherit the global value, disable it with 0, or set its own 1 to 600 s window (adDetectionExcludeStartOverride in Feed Settings). A marker that starts inside the window is not cut; markers that start at or after the boundary are handled normally. The setting takes effect on the next process or reprocess.

Tuning LLM behavior per stage

Each LLM pass can be tuned independently from Settings. The five passes:

  1. Ad Detection (Pass 1) - first scan of the full transcript
  2. Verification (Pass 2) - second scan against the processed audio
  3. Reviewer - optional confirm/reject pass (shared by both reviewer invocations)
  4. Chapter Boundary Detection - finds topic transitions
  5. Chapter Title Generation - writes titles for those chapters

Controls available on each:

ControlRangeNotes
Temperature0.0 - 2.00.0 is fully reproducible. Keep detection and chapter boundaries low.
Max tokens128 - 32768Response cap. Truncated JSON fails parsing; the salvage helper only recovers single-ad cases.
ReasoningProvider-awareAnthropic takes a numeric token budget (1024-65536) for the thinking block. OpenAI, OpenRouter, and Ollama take an effort level (none, low, medium, high).

Defaults match what the code used before this feature, so existing installs behave identically until you touch a control.

Fallback when the provider rejects a value

If a provider rejects temperature, reasoning, or thinking settings, that in-flight call can retry once with a compatible fallback. A concurrent call that records the same incompatibility does not consume the retry. Reasoning exhaustion retries with reasoning disabled. Processing history stores a sanitized notice for each pass whose reasoning or thinking value was rejected. The notice includes requested and fallback values but no provider error text. The episode page shows notices from the latest completed run. A later pass tries its configured values again.

Env-var defaults

Every tunable has a matching env var (DETECTION_TEMPERATURE, VERIFICATION_MAX_TOKENS, REVIEWER_REASONING_LEVEL, etc.). The env var supplies the default; a value saved in Settings wins over it, like every other env-backed setting. When the env var is set, the control shows a note naming the variable it inherits its default from. Full list in .env.example.

Ollama context window

Ollama truncates prompts that exceed its context window without telling you. The default is often 2048 tokens, too small for a full-transcript pass, and detection fails silently. When the active provider is Ollama, Settings exposes a Context window (num_ctx) field; set it to your model's trained context (8192 or higher on most modern models). Env-var alias: OLLAMA_NUM_CTX.

Detection window geometry

Long episodes are chunked into overlapping windows before being sent to the detection LLM. These controls are global rather than per-stage, and sit above the per-stage controls:

ControlRangeDefaultNotes
Window size120-10800 seconds600sHow much audio each detection request covers. Lower values reduce tokens per request and help small local models or low-tier provider plans stay under per-minute caps. Raise it toward the top of the range for low-request-rate providers (e.g. free tiers capped at a few requests per minute) so more of the transcript fits in fewer calls.
Window overlap0-1770 seconds180sTrailing overlap between consecutive windows so an ad straddling a boundary is still visible in the next window. Must be strictly less than window size.

API: PUT /api/v1/settings accepts windowSizeSeconds and windowOverlapSeconds. Cross-field validation rejects overlap >= size with a 400. The reset-to-default buttons in the UI clear the stored value so the built-in defaults apply on the next episode; no restart needed.

When the provider returns a 429 because a single window's request exceeds the per-minute token cap, MinusPod flags the episode with a Rate Limit Structural error and fires the matching webhook (see API & Webhooks). Lower Window size here, or move to a higher provider tier; the retry loop won't eventually succeed because the request itself is too big.

VAD Gap Detector (advanced)

Whisper uses Voice Activity Detection to skip regions it classifies as silence or non-speech. Sped-up legal disclaimers at the tail of DIA ads, distorted interstitials, and some ad intros fall into that bucket and never make it into the transcript. Since MinusPod's Claude, text-pattern, and roll detectors all run against the transcript, these regions are invisible to them and can leak into the processed output, usually at the very start or end of an episode.

The VAD gap detector runs after the other stages and treats untranscribed spans as ad candidates:

  • Head gap at the top of the episode: cut whenever the first transcribed segment starts more than VAD_GAP_START_MIN_SECONDS (default 3s) into the audio and nothing already covers it.
  • Mid gap between segments: if the span is adjacent to a detected ad, the ad's boundary is extended in place. Otherwise, the gap must be at least VAD_GAP_MID_MIN_SECONDS (default 8s) AND have ad-signoff language before it or show-resume language after it. Neutral content pauses are left alone.
  • Tail gap at the bottom: cut when the span is at least VAD_GAP_TAIL_MIN_SECONDS (default 3s) and the postroll detector hasn't already marked it.

Disable with VAD_GAP_DETECTION_ENABLED=false or via PUT /api/v1/settings {"vadGapDetectionEnabled": false}. The knob is intentionally not in the UI; operators reach it via env or API.

If the detector is cutting too aggressively on a specific podcast, raise the mid threshold before disabling. VAD_GAP_MID_MIN_SECONDS=15 or higher restricts the standalone mid path to very long spans; the adjacent-ad-extend path still fires regardless.

Provider API Keys

You can set the Anthropic, OpenAI-compatible, OpenRouter, Ollama, and remote Whisper keys from the UI (Settings > LLM Provider and Settings > Transcription) or via PUT /api/v1/settings/providers/<name>. No container restart needed. Keys are encrypted with AES-256-GCM.

Two things have to be in place first:

  1. MINUSPOD_MASTER_PASSPHRASE set in the container environment. PBKDF2 derives the encryption key from it, so treat it like any other production secret: back it up, keep it stable, and do not commit it. To rotate, stop every worker and run python scripts/rotate_master_passphrase.py. The online rotation endpoint refuses the request because sibling workers cannot safely receive a new passphrase.
  2. An admin password set in the UI, so Settings is reachable. The password gates the surface only; it isn't part of the crypto. Changing it leaves stored keys untouched.

If the passphrase is missing, the key inputs collapse to a "Setup required" note, the API returns 409 provider_crypto_unavailable, and env-var credentials keep working. GET responses never include key values, only booleans plus a db/env/none source marker.

Secondary provider

Alongside the primary provider above, you can configure one secondary provider: a separate provider type, base URL, and API key that a stage's provider selector can route to instead of primary. This is useful for splitting cost or capacity across two accounts, or across two different providers, without switching your main configuration back and forth.

The secondary provider is off by default, so an install with only a primary provider configured behaves exactly as before. Configure it via PUT /api/v1/settings/ad-detection:

  • secondaryProviderEnabled (boolean) - turns the slot on or off. A stage set to route to secondary while this is off falls back to primary.
  • secondaryProvider - the provider type: anthropic, openrouter, openai-compatible, or ollama. An empty value clears it.
  • secondaryProviderBaseUrl - base URL, used only when the type is openai-compatible or ollama. SSRF-validated the same way as the primary provider's base URL. Empty clears it back to the default.
  • secondaryProviderApiKey - API key for the secondary provider, encrypted the same way as every other provider key. Omit to leave it unchanged; null or empty clears it.

GET /api/v1/settings reports the current configuration as secondaryProviderEnabled, secondaryProvider, and secondaryProviderBaseUrl, plus secondaryProviderApiKeyConfigured (a boolean; the key itself is never returned). POST /api/v1/settings/providers/secondary/test-connection runs the same staged connection probe used for the primary provider, but against the secondary type, base URL, and key, so you can confirm it works before pointing a stage at it.

Changing any secondary provider field lifts an active rate-limit hold for that account, the same as changing the primary provider's credentials does.

Base URLs for either slot are rejected if they embed credentials in the user:pass@host form, on save and on a connection test alike. The URL is copied into each run's non-secret route snapshot and returned by GET /api/v1/settings, so an embedded password would leak. Put the key in the API key field instead. Remote Whisper is exempt, since its endpoint is not part of that snapshot.

Rotating or clearing a provider key

Saving a new key, or clearing one, takes effect without a container restart. Provider settings carry a revision marker that every write path bumps, and an already-built client checks that marker before each use, so a cached connection built on the old key is rebuilt rather than reused. The marker lives in the database, so sibling workers pick up the change too, not just the one that handled the request.

In practice:

  • A run in flight builds its next client against the new key. Only rotate keys this way when the provider account and endpoint remain the same.
  • Clearing a key really does disable that slot's calls. There is no grace period where the old key keeps working.
  • A rate-limit hold on that account is lifted by the same save, so the queue resumes as soon as the new key is in place.

Endpoint and model changes during an active run

Each run snapshots its routing when it starts: for every phase, the provider, the model, and the endpoint. The whole run then uses that snapshot, and it is stored with the run so a recovered run resumes on the same routes rather than silently re-resolving. Editing a stage's provider or model, or changing a base URL, therefore does not take effect on a run already underway; it applies to the next run. The ad reviewer freezes its own routing settings the same way, so a mid-run change cannot re-route review.

Credentials are the deliberate exception, as described above: the snapshot holds endpoints and model ids, never keys, and keys resolve when a client is built. Rotating a key changes what an in-flight run authenticates with; changing an endpoint does not change where it sends.

Changing a slot's provider or endpoint together with its key can therefore send the new key to the old endpoint from an active or recovered run. Until credentials are bound to the snapshot's account identity, finish or cancel affected runs before switching accounts. Pausing new work alone does not stop an active run.

Budgets when a cost is unknown

Provider admission reserves budget before a run starts and reconciles it afterwards against what the run actually spent. When some of that spend has no resolved cost, usually an unpriced or custom model, reconciliation stays conservative: instead of settling on the known-only subtotal, which would understate the run and release budget it may well have used, the reservation is settled as uncertain and keeps counting its reserved amount against the daily total.

Admission is cautious the same way. A run whose cost cannot be estimated is denied admission by default; the behavior is configurable to allow it outright, or to allow it against a fixed fallback reservation. A run abandoned by a dead worker is also settled as uncertain rather than released whenever it had already attempted a provider call.

If your daily budget looks consumed faster than your invoices suggest, unpriced models are the usual reason. Set prices for those model ids (Settings > AI & Processing > AI Models) and the estimates sharpen. The same condition surfaces per episode as hasUnknownCost and in the UI as a partial-total marker.

Which run a cost figure describes

Three different questions look alike on the episode page, so the API keeps them apart:

FieldQuestion it answers
activeRunSpendWhat is the run that owns this episode right now spending? Null when no run owns it.
latestRunSpendWhat did the most recent attempt spend? Includes failures and runs that made no LLM call.
cumulativeSpendWhat has this episode cost across every attempt ever made?

The active run and the latest attempted run are usually the same run. They diverge while a reprocess is in flight: activeRunSpend climbs live from the ledger, and latestRunSpend still describes the previous attempt, because a run's history row is only written when it finishes. Neither is the same as the run that produced the audio being served, which is the latest run that actually completed; a failed reprocess of a good episode changes latestRunSpend without changing what listeners hear.

JSON schema response format

OpenAI-compatible endpoints only. When on, MinusPod asks the endpoint to enforce a JSON schema on detection, review, category repair, and trim-recovery responses instead of only asking for JSON, which cuts malformed replies. Off by default; the toggle is in Settings > LLM Provider.

Support varies by model, not just by server. MinusPod probes each model the ad pipeline is configured to use, once, after the endpoint verifies, and stores one answer per model. Plain JSON mode is remembered the same way, so one model's rejection no longer speaks for the others on that endpoint. A model that rejects the schema falls back to plain JSON mode, and so does a model that passes the probe but rejects a real request. Check that your server implements response_format with type: json_schema before relying on it. Anthropic, OpenRouter, and Ollama call sites are unaffected by this toggle.

Cover art badge

Settings > Cover Art has an Overlay MinusPod badge on cover art toggle, off by default. When on, MinusPod adds a small badge to a corner of each served feed's cover art, so the filtered version is easy to tell apart from the original in your podcast app. Badge position picks the corner: bottom-right (default), bottom-left, top-right, or top-left, which helps when the show's own logo sits under the badge. ARTWORK_BADGE_POSITION seeds it on a fresh deploy. The badged image is served at /<slug>/cover-minuspod.jpg. A Refresh all artwork button in the same section re-renders every feed's cover art, which you run after toggling the setting or swapping the badge asset.

Pass-through mode

Pass-through is one of the five presets on each feed's Processing mode select (Feed Settings), alongside standard, keep content only, skip ad detection, and cue-only (experimental). Choosing it stops processing that feed's episodes entirely: each new episode is downloaded and relayed, with no transcription or ad removal. Audio may still be transcoded for serving, so pass-through is not a promise of byte-identical audio; see the caveat below. Useful for archiving originals, or for pausing ad removal on a feed without touching your podcast app.

The served feed URL does not change, which is the point: your app keeps pulling the same MinusPod feed, and switching to another mode resumes full processing for new episodes. Two caveats. First, the serving stack names episode files .mp3 and declares audio/mpeg, so an enclosure that is not already MP3 is re-encoded to MP3 at the configured bitrate, as is one whose codec ffprobe cannot identify; an MP3 enclosure is relayed as-is. Second, the download size cap (MINUSPOD_MAX_AUDIO_DOWNLOAD_MB, default 500) still applies, so raise it before archiving very large episodes. Episodes that were served untouched keep their original audio until you reprocess them. While the feed is on Pass-through, a full or AI reprocess just re-downloads the current copy; the per-episode Recut action still works on episodes that have a retained original and ad markers.

Segment categories

Every detected marker carries a category (what kind of content it is) that resolves to an action (what happens to the audio). A pattern's category is set on creation (the Category select, or category on import) and can be changed on the pattern detail modal. See How It Works > Segment Categories for the pipeline behavior, including the keep-action guards and how a changed action map applies to already-processed episodes.

CategoryCoversDetected by default
SponsorPaid host-read or produced reads, dynamic ad insertion, platform pre/post-rollsYes
Cross-promoOther-show and network promosYes
Self-promoPatreon, merch, subscribe/donate for the show itselfYes
InteractionFollow/rate/review promptsYes
IntroShow intro or themeOnly when Detect intro, outro, and housekeeping segments is on
OutroOutro and creditsOnly when Detect intro, outro, and housekeeping segments is on
Recap"Coming up", headline bumpers, "listen next" housekeepingOnly when Detect intro, outro, and housekeeping segments is on

Each category maps to one action:

ActionEffect
RemoveCut from the audio. Default for every category.
BeepReplaced with a tone; the episode's duration is unchanged.
KeepLeft in the audio untouched.

Resolution order: a per-feed override, if set, wins; otherwise the global default applies; otherwise the action is remove. Segment actions have their own dedicated Segment actions card in Settings (a sibling of Global Defaults, not nested inside it). Set the global map there. Set per-feed overrides on the feed's settings page under the same Segment actions heading; each category starts inherited from the global map until you set it explicitly. API: global map is segmentCategoryActions on PUT /api/v1/settings/ad-detection (a partial map, merged over the stored global map); per-feed overrides are segmentCategoryActions on PATCH /api/v1/feeds/{slug} (replaces the stored override map outright; null clears every override).

Show-segments detection (whether intro, outro, and recap markers get produced at all) has its own global default alongside the global action map on the Segment actions card, off by default, and saves immediately when toggled. A feed inherits that default until it sets its own value: the feed settings page exposes an explicit Inherit / On / Off choice (detectShowSegments on PATCH /api/v1/feeds/{slug}; null means inherit) and shows the effective value while inheriting. With detection off, the LLM never produces intro/outro/recap markers for that feed, so those rows of the action map have nothing to act on regardless of how they are set. With it on, intro/outro/recap detection is added to that feed's LLM detection windows; the other four categories are detected regardless of this setting.

To apply a new map to an already-processed feed, use the Re-render episodes with current segment actions button on the feed settings page (POST /api/v1/feeds/{slug}/rerender-segments). It recuts every processed episode that still has a retained original, saved transcript, and ad detections. Episodes that do not meet those preconditions are skipped, not counted as queued.

Queue priority

Each feed has a Queue priority: High, Normal (default), or Low, set on the feed's settings page. High processes ahead of other queued episodes; Low runs only once nothing else is waiting.

Three automatic boosts stack on top of a feed's base priority, and the size of each is a setting under Queue > Queue Control > Queue priority:

BoostDefaultWhen it applies
Play or reprocess20You press play on an unprocessed episode, or reprocess one by hand (Reprocess, Full Analysis, or Re-detect Ads). Always applies.
New episode5The episode's publish date is within 48 hours of now, and the global Process new episodes first toggle (on by default) is on.
Reprocess All0Bulk work: Reprocess All on a feed, or segment re-renders. The default of 0 keeps a backlog run behind everything else.

The defaults encode one rule: a request you make right now beats backlog work, always. Raise the Reprocess All boost only if you want backfills to compete with new releases.

Automatic changes only ever raise a queued episode's priority: pressing play on an episode already sitting in the queue lifts it to the play boost, and background refreshes can never knock a boosted episode back down.

You can override that by hand. Each row on the Queue page has -/+ buttons that can raise or lower its priority. The endpoint is POST /api/v1/feeds/{slug}/episodes/{episodeId}/queue-priority. Re-enqueueing the episode with a higher computed priority still overwrites a hand-set value, and so does a change to the feed's own Queue priority.

Changing a feed's queue priority restamps every episode of that feed still pending in the queue with the new base priority. API: queuePriority on PATCH /api/v1/feeds/{slug} (high, normal, or low); the boost sizes are queueManualBoost, queueFreshBoost, and queueBulkBoost (0-100) and the toggle is processNewEpisodesFirst, all on PUT /api/v1/settings.

Title blacklist

Each feed can list glob patterns under Skip episodes by title on its settings page. An episode whose title matches any pattern is skipped: it is never queued for automatic processing, and just-in-time processing (playing it) does not detect or cut it either.

Matching is against the whole title, case-insensitive. * is a wildcard; a pattern with no wildcard must match the entire title exactly, so a substring match needs * on both sides. For example Bonus Episode * skips any title starting with "Bonus Episode", and *live show* skips any title containing "live show" anywhere.

A per-feed Skipped episodes choice decides how a skipped episode is served: Keep in feed with original audio (default) serves it unmodified in the RSS feed, or Hide from feed drops it from the served feed entirely. Either way the episode is unaffected by the blacklist if you reprocess it manually: a manual reprocess always overrides the blacklist and processes the episode normally.

API: titleSkipPatterns (array of strings, max 50 patterns, 200 characters each) and titleSkipAction (serve_original or hide) on PATCH /api/v1/feeds/{slug}.

Recents feed

One optional combined feed at /recents carries every episode processed on this instance whose publish date is on or after the day the feed was created, across all subscribed and local feeds. Subscribe to it once and podcasts you add later show up in it without another OPML import. Create it from Add Feed (the option disappears once it exists); rename it, set a description, and replace its artwork (the MinusPod icon by default) from its feed page. Each item points at its source feed's audio, transcript, and chapters, so nothing is copied. Episodes published before the feed existed stay out even when they are reprocessed. The modified OPML export lists it alongside your other feeds; the original export skips it, since it has no upstream URL.

Per-feed retention

Global retention lives in Settings > Storage & Retention and applies to every feed. Any single feed can override it from its own settings page with the Retention control, which offers three choices:

  • Use global (default) follows the global window, so nothing changes for existing feeds.
  • Keep for N days sets a window for this feed alone. Use a short window on a daily news show you never revisit, or a long one on a show you catch up with slowly.
  • Archive, never delete keeps every processed episode indefinitely. This is the option for shows that have stopped publishing, where a swept episode is gone for good because the publisher's feed no longer carries it.

An archived feed is also skipped by the Clear all processed audio action in Settings. That action is an explicit operator wipe and overrides the global retention window, but a per-feed archive is a deliberate "never delete this show", so it wins. A feed that inherits a globally disabled retention is still wiped by that action.

Archive keeps the pre-cut original audio as well as the cut version. To archive a show without paying for the uncut copies, set Original audio to "Discard the uncut copy" on the same page.

The Original audio control overrides the global "Keep original audio" toggle for one feed, with the same three choices: inherit, keep, or discard. The pre-cut audio is what Review mode in the ad editor plays, so discarding it disables that button for episodes processed afterwards. It roughly halves what the feed stores. The change applies to the next episode processed; it does not delete originals already on disk.

API: retentionDaysOverride (integer, null to inherit, 0 to archive, 1 to 3650 for a day count) and keepOriginalAudioOverride (boolean or null to inherit) on PATCH /api/v1/feeds/{slug}.

Blocked user agents for just-in-time processing

Settings > Security has an Agents that skip processing list, empty by default. If a listed User-Agent asks for an episode MinusPod has not processed yet, MinusPod answers with a 302 to the original audio URL rather than queueing a transcription and ad-detection run. Already-processed episodes are unaffected and still serve the cut version to every client, listed or not.

Matching is case-insensitive, and a bare pattern matches anywhere in the agent string. Start a pattern with ^ to anchor it to the beginning, which short strings like atc/ need so they cannot match in the middle of an unrelated agent. Each entry is limited to 200 characters; blank or whitespace-only entries are dropped when saved. The opawg/user-agents registry lists the real strings crawlers and podcast apps send, useful when picking a pattern.

API: jitBlockedUserAgents (array of strings) on PUT /api/v1/settings/ad-detection.

Experiments

The Experiments section in Settings holds opt-in features that are still being evaluated. Everything here is disabled by default. Turning a feature on does not change behavior on existing processed episodes; it applies only to subsequent processing runs.

Ad Addressing Mode

Settings > Experiments has an Ad addressing mode select, marked experimental. Timestamps, the default, asks the model for a start and end time for each ad. Segment IDs asks the model to name numbered transcript lines instead; MinusPod then maps those line numbers back to the exact Whisper times, so the model never has to guess a timestamp. Random draws one of the two per detection run (and independently again for the verification pass), so production traffic accumulates an unbiased comparison over time. Segment IDs is still being benchmarked against Timestamps, and the results decide whether the default ever changes.

How often each mode's LLM contract is actually honored shows up on the Stats page, under Addressing modes: runs, windows judged, and compliance percentage per mode. Random-mode runs count toward whichever mode was drawn for that pass.

Default timestamps. API: PUT /api/v1/settings/ad-detection with adAddressingMode (timestamps, segment_ids, or random).

The Stats page tracks two things per mode. Contract compliance says whether the model used the requested output shape; both modes hold near 100% and it exists mostly as a canary. Ad yield is the comparison that matters: how many ads each mode proposed, how many survived into the pipeline, and why the rest were dropped. The "invalid ref" drop count only exists for segment IDs, and that asymmetry is the point of the experiment: a made-up segment ID is caught and dropped, while a made-up timestamp sails through and has to be caught by later validation, if it is caught at all.

Yield is recorded from 2.92.0 on. Older runs carry no yield data and are excluded from the yield numbers, so the yield sample starts empty and can lag the compliance sample.

Prompt placeholders

Detection, verification, and reviewer prompts use explicit placeholder substitution rather than always appending dynamic content. Available placeholders:

  • {sponsor_database} - substituted at runtime with the dynamic sponsor list (the one that grows as new sponsors are detected). Available in the system, verification, review, and resurrect prompts. If you remove this placeholder from your customized prompt, no sponsor list is injected on that prompt.
  • {max_boundary_shift_seconds} - review prompt only. Substituted with the current Max boundary shift setting. The boundary cap is enforced in code regardless of whether the placeholder is in the prompt.
  • {override} - replaced with that pass's override text (see below). If a customized prompt omits it, the override is appended instead.

If you customized your system or verification prompt before this release, the upgrade automatically appends {sponsor_database} to your prompt so behavior is preserved. The migration is idempotent and runs once.

Per-pass prompt overrides

Each pass (first, verification, reviewer, resurrect) has an optional Override field in Settings, empty by default. Text there is appended to that pass's prompt at run time, so you can add a tweak (e.g. "keep this show's news roundup") without editing the built-in prompt. To put it somewhere other than the end, add {override} to a customized prompt where you want it. An empty override changes nothing.

Per-feed detection notes

The global overrides apply to every feed. For one show, use Detection notes on the feed's settings page (up to 1000 characters). The text is appended to the podcast description the model already sees, so it reaches the first pass and the reviewer. Use it for things only that show does: how its intro is structured, how host-read ads usually start, a recurring segment to keep. API: detectionNotes on PATCH /api/v1/feeds/{slug}.

Audio Cue Detection

Audio cue detection snaps ad cuts to a show's recurring chime or stinger, and is off by default. Setup, cue types, the find-audio-cues scan, and every tuning control are documented in Audio Cue Detection.

Reprocessing

Reprocessing an episode re-runs detection without re-fetching it from the source feed. The episode menu offers four modes; the bulk feed actions offer the same set apart from Recut Audio:

  • Reprocess (default) - uses the learned pattern database plus the LLM. Fastest option for routine re-detection.
  • Full Analysis - skips the pattern database for a fresh LLM-only pass.
  • Recut Audio - re-cuts the retained original from the episode's current ad list and re-times the saved transcript, without re-transcribing or calling the LLM. Use it after editing ads by hand to regenerate the output file. Because no LLM runs, generated chapters are not refreshed: the rebuilt file carries the source feed's own chapters remapped to the new cut, and the podcast:chapters JSON keeps its old timestamps. Run Regenerate Chapters afterward if chapters matter for the episode.
  • Re-detect Ads - reruns detection and re-cuts using the transcript already saved for the episode, skipping the transcription step that dominates processing time on local hardware. Requires an existing transcript; episodes without one are skipped, and it is also offered for failed episodes that still have a transcript. Use it to iterate on detection settings or models without paying for transcription each time. Not available on a feed set to Pass-through, skip ad detection, or cue_only mode (returns a 409): none of those modes has a detection LLM call to rerun, so Recut Audio is the equivalent action after editing ad markers by hand.

Community Patterns (Optional)

MinusPod can share and receive ad patterns from a community-maintained seed list. Patterns describe recognized ad reads (sponsor scripts, host-read pre-rolls, etc.) so new MinusPod instances skip the LLM detection step for ads that have already been identified elsewhere.

The feature is opt-in and off by default. When enabled, your MinusPod instance pulls a manifest of community patterns from this repo on a schedule you control. To submit your own patterns back, open the Patterns page Export dialog and pick Submit to community: the app runs quality gates over your selection, shows what will pass, and downloads a single bundle file. Drop it into your fork of patterns/community/ and open one PR.

What you control

  • Sync schedule - cron expression in Settings (default: weekly, Sunday 3am)
  • Manual sync - "Sync now" button in Settings
  • Per-pattern protection - pin any community pattern with Protect from sync to prevent automatic updates or deletion
  • Disable at any time - flipping the toggle stops sync; existing community patterns remain unless you delete them
  • Remove all at once - "Remove all community patterns" in Settings wipes every community pattern (including any you marked Protect from sync). Useful for a clean reset before re-enabling sync.

What is shared if you submit

Submitting a pattern is a separate action you trigger from the Export dialog and never automatic. Before submission, the app:

  • Strips local identifiers (which podcast, which network, your match counts, your timestamps)
  • Strips PII from pattern text (consumer email addresses, non-toll-free phone numbers)
  • Validates the pattern meets quality thresholds
  • Generates a JSON file and opens a prefilled GitHub PR in your browser

You retain everything locally. Submission is a copy, not a move.

Full details

See patterns/README.md for the technical reference (sync mechanics, file formats, tag vocabulary) and patterns/CONTRIBUTING.md for what happens when you submit a pattern.

Splice check

A long cut is held for review unless the audio carries evidence of an insertion point near its edges. See How It Works > Held for Review for what counts as evidence, and why a feed whose ads are read straight through in one take has every long cut held.

The per-feed Splice check setting, under Advanced on the feed's settings page, is the way out. It overrides the global either way, so a feed that can never satisfy the check stops being held by it without changing anything for your other feeds.

SettingEffect
Use globalFollows the global splice_veto_enabled setting, on unless an operator changed it
Hold cuts without splice evidenceForces the check on for this feed
Cut without splice evidenceTurns it off for this feed, so long cuts are judged on the other validation rules alone

Turning it off gives up a safety net, so it suits a feed you have already watched cut correctly. Held ads are never lost either way: they stay in the audio and wait on the episode page.

Offline Queue

If your LLM or Whisper server only runs part of the day (a desktop PC that hosts Ollama, for example), episodes that arrive while it is off normally retry a few times, trip the circuit breaker, and end up permanently failed until you reprocess them by hand. The offline queue changes that: an episode that fails because the endpoint is unreachable is parked with a "queued (offline)" status instead. Every few minutes MinusPod probes the endpoint, and once it answers again the parked episodes go back into the processing queue on their own.

The feature is off by default. Configure it in Queue > Queue Control.

SettingDefaultNotes
EnabledoffPark episodes when the LLM or Whisper endpoint is unreachable.
Give up after48 hoursEpisodes still waiting after this long are marked failed and logged. Range 1-720 hours.

Only connection-level failures qualify: connection refused, DNS errors, timeouts, and repeated 5xx responses. Auth errors, rate limits, and bad responses still fail normally, so a wrong API key does not sit in the queue looking healthy. Turning the toggle off stops new episodes from being parked, but anything already waiting keeps being probed and expired so nothing is stranded. You can also reprocess a parked episode by hand at any time.

Rate-Limit Hold

Hosted LLM providers answer a 429 with the time their limit resets. Without this feature an episode that hits one burns its retries against a provider that will not answer for another hour, and every episode behind it does the same. The rate-limit hold puts the episode back in the queue instead and stops the queue from claiming anything until the reset time passes, then carries on by itself.

The feature is off by default. Configure it in Queue > Queue Control.

SettingDefaultNotes
EnabledoffPause the queue when the provider reports a 429 with a reset time.
Usage endpoint(unset)Optional URL returning provider usage/limit JSON (env LLM_USAGE_URL); checked first while a hold is active.
Check every5 minutesHow often the probe re-checks an active hold. Range 0-60 minutes; 0 turns it off (env RATE_LIMIT_PROBE_MINUTES).

Only a reset further out than five minutes triggers a hold. Shorter ones keep the existing in-process retry, so a single throttled window recovers without pausing the queue. The hold covers detection, review, and verification, so a throttle part-way through a run sends the whole episode back to the queue rather than skipping that stage. Nothing bypasses the pause: Play and Reprocess wait with the rest, because a hand-picked episode would only hit the same 429. Held episodes keep their queue position and status, so there is no give-up window and nothing to release. Turning the toggle off lifts an active pause at once.

While a hold is active, a probe re-checks it instead of waiting out the provider's stated reset. With a usage endpoint configured it is checked first and can clear the hold early or push it out to a fresher reset; without one, a single minimal completion call does the same check.

A hold on one provider is lifted automatically when you update that provider's own credentials in Settings > Providers, or when you turn this toggle off. Changing several provider settings together in one save does not clear a hold that belongs to just one of them.

Holds are scoped per credential, not just per provider type. If primary and secondary use the same provider type, for example two Anthropic accounts, a 429 on one does not pause the other. Updating a slot's credentials lifts only that slot's hold.

Manual request-rate limits

The rate-limit hold above reacts to a 429 after it happens. Manual request-rate limits check recent ledger usage before a tracked call. They are best-effort controls: concurrent calls can pass the check together, and adapter-internal compatibility retries are not recorded as separate requests. Token limits use recorded usage, not a reservation for the next request, so a call can cross the configured token limit. Both features share the same queue-hold machinery, so a manual limit pauses the queue exactly like a real 429 and resumes on its own. A manual hold clears only when its reset time passes, and is never cleared early by the usage probe (that probe sends a real request, which would burn the quota the cap protects).

Limits are scoped to one provider account, meaning one credential slot on one provider type: primary and secondary count separately even when both point at the same provider. Counting comes from the LLM call ledger: requests in the last 60 seconds against the per-minute cap (RPM), input plus output tokens of finalized calls in the last 60 seconds against the tokens-per-minute cap (TPM), and requests since the last UTC midnight against the per-day cap (RPD). When any cap is reached, that account's queue is paused. A per-minute pause (RPM or TPM) lifts about 60 seconds after the oldest contributing call in the window; the per-day pause lifts at the next UTC midnight, which is the fixed reset boundary regardless of your server's timezone or the provider's own billing day. When more than one cap is over, the later reset wins.

All are off by default (0 means unlimited), so existing installs are unaffected. Configure them under Settings > AI & Processing > LLM Provider, or via PUT /api/v1/settings/ad-detection:

  • providerRequestsPerMin, providerRequestsPerDay, providerTokensPerMin - caps for the primary provider account (env PROVIDER_REQUESTS_PER_MIN, PROVIDER_REQUESTS_PER_DAY, PROVIDER_TOKENS_PER_MIN).
  • secondaryProviderRequestsPerMin, secondaryProviderRequestsPerDay, secondaryProviderTokensPerMin - caps for the secondary provider account (env SECONDARY_PROVIDER_REQUESTS_PER_MIN, SECONDARY_PROVIDER_REQUESTS_PER_DAY, SECONDARY_PROVIDER_TOKENS_PER_MIN).

Example, using figures that were current for one provider's free tier at the time of writing: an account allowed 5 requests per minute and 20 per day. Providers change their tiers often, so read your own account's limits rather than trusting this number, then set providerRequestsPerMin to 5 and providerRequestsPerDay to 20. Pair this with a large detection window size (see Detection window geometry) so each episode spends fewer requests, and a whole episode can fit inside a small daily budget. The token-per-minute allowance on a tier like that is often generous enough that TPM is not the binding limit, but you can set providerTokensPerMin if your account has a tighter token budget.

A held or limited provider never reroutes to another provider: the episode waits in the queue for that account's reset.

Whisper Pool

Off by default. With a remote Whisper backend (WHISPER_BACKEND=openai-api) that accepts several requests at once, the pool lets MinusPod process more than one episode at a time and share one cap on in-flight transcription requests between them. Configure it in Settings > Transcription under the remote backend fields.

SettingEnvDefaultNotes
Whisper poolWHISPER_POOL_ENABLEDoffNothing below applies while this is off or the backend is local.
Max requests to backendWHISPER_POOL_MAX_REQUESTS4Transcription requests in flight at once, across every episode. Range 1-64. Set it to what your backend accepts.
Episodes at onceWHISPER_POOL_MAX_EPISODES1Episodes processed concurrently. Range 1-16.

While the pool is on, every run starts from the background worker, so a Play on an idle instance begins within a few seconds instead of at once. Each episode keeps at least one request slot. Leftover slots go to chunk parallelism, so "Concurrent chunks" is capped by the request cap divided by the episodes transcribing. GET /api/v1/settings/whisper/capacity reports the resolved numbers, and the settings page shows the worst case (episodes times concurrent chunks) against the cap. A 429 from the backend waits and retries instead of counting as a failed chunk. Turning the pool off lets runs in flight finish and returns new runs to the single-slot path.

When a backend at https://your-whisper-host/v1 exposes an optional health endpoint, MinusPod samples it to report the replica count, model, device, and compute type per instance, plus a suggested "Max requests" value based on their combined concurrency. This is entirely optional: without a health endpoint, nothing changes and the cap stays a manual setting. The probe always reads {base}/health (e.g. https://your-whisper-host/v1/health), so a server that exposes health at its root rather than under the API path reports nothing.

For a worked example of a multi-replica backend, including the health endpoint this reads, see whisper-pool. MinusPod works with any OpenAI-compatible Whisper server. That one happens to expose the fields described above.

Outbound Requests

MinusPod identifies itself with two User-Agent strings, and hosts treat them differently. Bot mitigation on some CDNs refuses browser identifiers below a version floor that moves as new browsers ship. A string that worked last year starts drawing a 403 on download, even though the file is there. Other feed hosts do the reverse and answer only a declared podcast client. One string cannot satisfy both, so there are two.

Both ship with working defaults and are editable in Settings > Data & Security > Outbound Requests. A host that starts refusing yours is fixed by pasting in a new string rather than by waiting for a release.

SettingDefaultSent when
Audio, artwork, and chaptersa current Chrome stringDownloading media, feed artwork, and upstream chapter files.
RSS feedsPodcastAdRemover/1.0Fetching and validating RSS.

A value must be printable ASCII on a single line, at most 512 characters. Carriage returns and line feeds are rejected, since the value goes straight into a request header. Reset returns a field to its default.

What the download logs record

Every download and availability check logs the URL it requested, including the path, followed by each redirect hop with its status code and the final URL it landed on. That is usually enough to see which file was asked for and where the host sent it.

Query strings are left out. On a podcast enclosure that is where a signed CDN token or a per-listener tracking id lives, and a log outlives both. The "Log query strings on downloads" toggle in the same settings section adds them when you are debugging a refusal that depends on one. Turn it back off afterwards.

Diagnosing a refusal

When the availability probe draws a 403, MinusPod probes once more with the feed User-Agent. If that string is accepted, the host is gating on the browser identifier: the episode downloads with the feed string and a warning names both strings, so the download User-Agent is the one to change. Try the User-Agent your own browser sends. If both strings draw a 403, the host is blocking regardless of identifier, which is what bot mitigation and rate limits look like. The episode then retries on the normal ladder as CDN blocked the request (403) with both User-Agents. A 404 also retries, since a freshly published episode can 404 briefly while its host provisions the media URL.

Scheduled Database Backups

MinusPod can snapshot its SQLite database to a directory on a cron schedule. The feature is off by default. The "Back up now" button runs a snapshot immediately whether or not the schedule is enabled, and is rate-limited to 6 runs per hour. Configure it in Settings > Data & Security > Scheduled Backups.

SettingDefaultNotes
EnabledoffTurn on the cron schedule. Back up now works regardless.
Schedule30 3 * * *Cron expression, interpreted as UTC.
Destination/app/data/backupsDirectory path inside the container. Empty uses the default.
Keep last11 overwrites a single file; higher keeps timestamped copies and prunes the oldest.

Cron examples (all UTC):

  • 30 3 * * * - daily at 03:30
  • 0 */6 * * * - every 6 hours, on the hour
  • 0 4 * * 0 - weekly, Sunday at 04:00

The snapshots are plain SQLite files and are never encrypted, even with MINUSPOD_MASTER_PASSPHRASE set. For filenames, restore steps, and how destination directory permissions are handled, see Scheduled database backups in the security guide.

Feed Refresh and Podping

MinusPod polls every feed's upstream RSS on a fixed schedule. Podping is an opt-in accelerator that can trigger an immediate refresh of a single feed when its host announces a new episode; scheduled polling never turns off, so it stays the fallback for hosts that don't send Podping and for any notification the listener misses. See Podcasting 2.0 > Podping for how the listener works, which hosts send Podping, and the per-feed Podping coverage line on the feed detail page.

SettingDefaultNotes
Feed refresh interval15 minutesMinutes between background RSS refresh passes for every feed. Range 5-1440. Settings > Global Defaults. A change applies after the wait already in progress finishes.
Podping notificationsoffOpt-in listener that refreshes a feed immediately when its host sends a Podping notification. While enabled, it checks every RPC node at startup and every five minutes. System Health can run the same check on demand while the listener is off. Settings > Global Defaults.

< Docs index | Project README