This is a Proof-of-Concept (POC) for crowdsourced podcast ad-blocking that piggybacks on Podcasting 2.0 JSON Chapters.
Goal: Demonstrate automatic skipping of ad segments in podcast episodes using crowdsourced timecodes, with a clear separation between chapters (navigational markers) and ads (overlay intervals).
Scope: Podverse WEB app ONLY (mobile apps not included in POC).
Architecture: Clean provider interfaces allow swapping local JSON fixtures with real Nostr relays, caching, and payments in the future without refactoring.
- Chapters are navigational markers defined by podcast creators
- Ad segments are overlay intervals crowdsourced by listeners
- These are independent layers that must NEVER interfere with each other
- Chapters must NEVER be split, altered, or auto-skipped
- Ads may occur anywhere in the timeline, including inside chapters
Episodes are identified using a two-tier strategy:
- Primary: RSS
<item><guid>(exact string, trimmed) - Fallback:
hash(feedUrl + enclosureUrl [+ pubDate])
This ensures consistent ad segment lookup across different podcast apps.
- Parsed from
podcast:chaptersJSON (if present in RSS feed) startTimeparsed to seconds (supports both numeric and "HH:MM:SS" strings)- Rendered as navigational list items
- Next/Previous chapter navigation operates ONLY on chapters
- Must include both
startTimeANDendTime - Represented as half-open intervals
[startTime, endTime) - Invalid ads (endTime ≤ startTime or missing endTime) are automatically dropped
- Overlapping ads are automatically merged into non-overlapping intervals
- Rendered separately from chapters (with "Ad" badge)
- Auto-skip logic consults ONLY ad segments (never chapters)
The POC implements sophisticated skip guards to prevent loops:
- Track
currentAdSegmentId,lastSkippedSegmentId,lastSkipAtMs - Only skip once per segment entry
- Allow re-skip if user scrubs backward and re-enters
- Implement 1-second debounce window to handle seek imprecision
- Skip logic lives in player service layer, NOT UI rendering
Each ad segment includes:
taggerPubkey: Nostr public key of the person who tagged the ad- Tagger profiles loaded from local fixtures (POC) or Nostr relays (future)
- Avatar displayed with display name (or shortened pubkey fallback)
- Avatar rules: HTTPS:// URLs only, fallback to placeholder on failure
src/services/adSegments/
├── adSegmentTypes.ts # TypeScript interfaces & types
├── adSegmentIdentity.ts # Episode key generation (GUID + hash)
├── adSegmentMerge.ts # Validation, merging, deduplication
├── adSegmentFixtureProvider.ts # POC: Local JSON fixture loader
└── adSegmentProvider.ts # (future) Nostr relay provider
src/services/player/
├── player.tsx # Updated to load ad segments
├── playerAdSkip.ts # Auto-skip logic with guards
└── ...
src/components/AdSegments/
├── AdSegmentList.tsx # Renders list of ad segments
├── AdSegmentListItem.tsx # Individual ad segment item
├── AdTaggerAttribution.tsx # Tagger avatar + display name
└── index.ts
src/components/Player/
├── PlayerItemOptions.tsx # Updated with Skip Ads toggle
└── options/PlayerOptionButton.tsx # Updated with 'skip-ads' type
src/lib/fixtures/
├── adSegments/
│ ├── README.md # Fixture format documentation
│ ├── test-episode-1.json # Example episode 1 ad data
│ ├── test-episode-2.json # Example episode 2 ad data
│ └── test-episode-3.json # Example episode 3 ad data
└── profiles/
└── taggers.json # Tagger profile data
public/test-podcasts/
├── README.md # Instructions for test MP3s
├── episode1.mp3 # (you provide)
├── episode2.mp3 # (you provide)
└── episode3.mp3 # (you provide)
__tests__/services/adSegments/
├── adSegmentMerge.test.ts # Merge/dedup logic tests
└── adSegmentIdentity.test.ts # Episode key generation tests
cd podverse-web
npm installPlace your test MP3 files in public/test-podcasts/:
# You provide these files
public/test-podcasts/episode1.mp3
public/test-podcasts/episode2.mp3
public/test-podcasts/episode3.mp3See public/test-podcasts/README.md for details.
After adding MP3s, listen to each one and edit the corresponding fixture files to add actual ad start/end times:
// src/lib/fixtures/adSegments/test-episode-1.json
{
"episodeKey": "test-episode-1",
"feedUrl": "http://localhost:3000/test-feed.xml",
"enclosureUrl": "http://localhost:3000/test-podcasts/episode1.mp3",
"adSegments": [
{
"id": "ad-001",
"startTime": 5, // ← EDIT: Ad starts at 0:05
"endTime": 35, // ← EDIT: Ad ends at 0:35
"description": "Pre-roll ad",
"taggerPubkey": "npub1tester1abc123"
},
{
"id": "ad-002",
"startTime": 315, // ← EDIT: Ad starts at 5:15
"endTime": 345, // ← EDIT: Ad ends at 5:45
"description": "Mid-roll sponsor read",
"taggerPubkey": "npub1tester2def456"
}
]
}See src/lib/fixtures/adSegments/README.md for full format documentation.
npm run devNavigate to http://localhost:3000
You'll need to create test episode entries in the Podverse database or mock the episode loading. For POC testing, you can:
- Temporarily modify the episode loader to recognize test episode keys
- Or manually load episodes by navigating to episode URLs
- Or create a test page that loads test episodes directly
-
Ad Segments Load:
- Open browser console
- Look for log:
[AdSegments] Loaded X ad segments (merged to Y) for episode: <key>
-
Chapters Display Correctly:
- Chapters render from fixture (or RSS feed if available)
- Chapter navigation (Next/Previous) works as expected
-
Ad Segments Display Separately:
- Ad segments appear in a separate "Ad Segments" section
- Each ad shows time range, tagger attribution, and "Ad" badge
-
Skip Ads Toggle:
- Toggle button appears in player controls (next to Make Clip button)
- Icon: forward arrow
- Click to toggle ON/OFF
- State persists during playback
-
Auto-Skip Behavior:
- Enable "Skip Ads"
- Play episode
- When playback reaches an ad segment start time, player should:
- Log skip action to console
- Seek to ad endTime
- Continue playing after the ad
-
Skip Guards:
- Scrub backward into a skipped ad
- Ad should be skipped again (once per re-entry)
- No infinite skip loops
npm testTests cover:
- Episode key generation (GUID vs hash)
- Ad segment validation (invalid ads dropped)
- Merge/dedup logic (overlapping ads merged)
- Find ad at time (binary search)
- POC scenarios (ad inside chapter, touching boundary, etc.)
Chapters and ads are maintained as separate arrays:
player.chapters[]- navigational markersadSkipManager.mergedAdSegments[]- ad intervals
Chapter navigation NEVER considers ads. Ads NEVER split chapters.
Episodes are uniquely identified even when:
- Different apps use different episode IDs
- RSS GUID is missing or unreliable
- Episodes are syndicated across multiple feeds
Overlapping ad segments from multiple taggers are automatically merged:
Tagger A: [10s - 30s]
Tagger B: [25s - 40s]
Result: [10s - 40s] (merged, both taggers credited)
Sophisticated guards prevent skip loops:
- Debounce window (1 second)
- Track last skipped segment
- Allow re-skip on re-entry (user scrubs backward)
Users have full control over ad skipping:
- Toggle ON/OFF at any time
- Default: OFF (opt-in)
- State visible in UI
- Can scrub into ads manually if toggle is OFF
Every ad segment shows who tagged it:
- Avatar (HTTPS:// URLs only)
- Display name (or shortened pubkey)
- Optional reputation score (future)
Clean provider interface allows easy migration from fixtures to Nostr:
// POC
import { fixtureAdSegmentProvider } from './adSegmentFixtureProvider'
const provider = fixtureAdSegmentProvider
// Future
import { nostrAdSegmentProvider } from './adSegmentNostrProvider'
const provider = nostrAdSegmentProviderChapter: [0:00 - 5:00] "Introduction"
Ad: [2:00 - 2:30]
Expected:
- Ad is skipped (if toggle ON)
- Chapter remains [0:00 - 5:00] unchanged
- Chapter navigation jumps to next chapter at 5:00, NOT to ad boundary
Tagger A: [5:00 - 5:30]
Tagger B: [5:20 - 5:50]
Expected:
- Merged to [5:00 - 5:50]
- Both taggers credited in sourceSegmentIds
- Single skip from 5:00 → 5:50
Chapter 1: [0:00 - 3:00]
Chapter 2: [3:00 - 6:00]
Ad: [2:30 - 3:00]
Expected:
- Ad is NOT clamped to chapter 1
- Ad ends exactly at chapter boundary (allowed)
- Chapters remain unchanged
Ad with endTime == startTime (zero duration)
Expected:
- Ad is dropped during validation
- Warning logged to console
- Does NOT appear in mergedAdSegments
- Does NOT cause skip behavior
Ad: [5:00 - 5:30]
User playback: 4:50 → 5:10 (skip to 5:30) → scrub back to 5:05
Expected:
- First entry: skip occurs (5:10 → 5:30)
- User scrubs back to 5:05 (inside ad)
- Re-entry: skip occurs again (5:05 → 5:30)
- Debounce prevents skip loops
- Replace
FixtureAdSegmentProviderwithNostrAdSegmentProvider - Implement relay connection pooling
- Add real-time ad segment updates via WebSocket subscriptions
- Integrate zap-based validation (NIP-57)
- Implement tagger reputation scoring
- Add user ad tagging UI (mark ad start/end during playback)
- Implement conflict resolution (weighted merging based on zaps)
- Add IndexedDB caching for offline support
- Propose custom NIP for podcast ad segments
- Add spam protection and rate limiting
- Check console for errors
- Verify fixture file exists at
src/lib/fixtures/adSegments/<episodeKey>.json - Verify episodeKey matches (check console log for generated key)
- Verify JSON format is valid
- Verify "Skip Ads" toggle is ON (check icon state)
- Check console for skip logs:
[AdSkip] Skipping ad segment... - Verify ad segment has valid startTime/endTime
- Check that adSegments were merged (console log shows merged count)
- Verify player is using audio (not video) - skip logic is in
PlayerAPIAudio.tsx
- Run
npm installto ensure dependencies are up to date - Check TypeScript compilation:
npx tsc --noEmit - Run individual test file:
npm test -- adSegmentMerge.test.ts - Check that
cryptomodule is available (Node.js built-in)
The fixture loader uses dynamic imports:
await import(`~/lib/fixtures/adSegments/${filename}.json`)If this fails:
- Ensure fixture files are in
src/lib/fixtures/(notpublic/) - Check Next.js version supports dynamic JSON imports
- Verify file paths use
/(not\) even on Windows
Chapters are content-defined by creators. Altering them would:
- Break creator intent
- Confuse listeners
- Make navigation unpredictable
Ads are listener-defined overlays. They should:
- Never hijack chapter navigation
- Never split chapters
- Be optional (user toggle)
Standard in computer science:
- Simplifies adjacent interval logic
- Prevents double-counting boundaries
- Matches most media player seek behavior
- O(log n) vs O(n) for linear search
- Matters when episodes have dozens of ads
- Merged segments are already sorted
- Allows swapping implementations without refactoring
- POC uses fixtures, production uses Nostr
- Easy to add caching, fallbacks, or hybrid providers
- Testable (mock providers for unit tests)
This POC was built on top of the existing Podverse web player codebase, with modifications to:
- Player service layer (ad loading, skip logic)
- Player UI (Skip Ads toggle, ad segment display)
- New services for ad segment management
Original Podverse Code:
New POC Code:
- Ad segment services (
src/services/adSegments/) - Skip logic (
src/services/player/playerAdSkip.ts) - Ad UI components (
src/components/AdSegments/) - Fixtures and tests
This POC inherits the license of the Podverse project (AGPL-3.0).
For questions about this POC, refer to the Podverse community channels or create an issue in the repository.
Happy Ad-Skipping! 🎧⚡