Skip to content

Latest commit

 

History

History
480 lines (353 loc) · 14.1 KB

File metadata and controls

480 lines (353 loc) · 14.1 KB

Podcast Ad-Blocking POC

This is a Proof-of-Concept (POC) for crowdsourced podcast ad-blocking that piggybacks on Podcasting 2.0 JSON Chapters.

Overview

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.

Core Principles

1. Independence of Chapters and Ads

  • 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

2. Episode Identity Resolution

Episodes are identified using a two-tier strategy:

  1. Primary: RSS <item><guid> (exact string, trimmed)
  2. Fallback: hash(feedUrl + enclosureUrl [+ pubDate])

This ensures consistent ad segment lookup across different podcast apps.

3. Data Models

Chapters

  • Parsed from podcast:chapters JSON (if present in RSS feed)
  • startTime parsed to seconds (supports both numeric and "HH:MM:SS" strings)
  • Rendered as navigational list items
  • Next/Previous chapter navigation operates ONLY on chapters

Ad Segments

  • Must include both startTime AND endTime
  • 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)

4. Auto-Skip Logic with Guards

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

5. Ad-Tagger Attribution

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

File Structure

Core Services

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
└── ...

UI Components

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

Fixtures (POC Data)

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

__tests__/services/adSegments/
├── adSegmentMerge.test.ts             # Merge/dedup logic tests
└── adSegmentIdentity.test.ts          # Episode key generation tests

Getting Started

1. Install Dependencies

cd podverse-web
npm install

2. Add Test MP3 Files

Place 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.mp3

See public/test-podcasts/README.md for details.

3. Edit Ad Timecodes

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.

4. Run Development Server

npm run dev

Navigate to http://localhost:3000

5. Test the POC

Load a Test Episode

You'll need to create test episode entries in the Podverse database or mock the episode loading. For POC testing, you can:

  1. Temporarily modify the episode loader to recognize test episode keys
  2. Or manually load episodes by navigating to episode URLs
  3. Or create a test page that loads test episodes directly

Verify Functionality

  1. Ad Segments Load:

    • Open browser console
    • Look for log: [AdSegments] Loaded X ad segments (merged to Y) for episode: <key>
  2. Chapters Display Correctly:

    • Chapters render from fixture (or RSS feed if available)
    • Chapter navigation (Next/Previous) works as expected
  3. Ad Segments Display Separately:

    • Ad segments appear in a separate "Ad Segments" section
    • Each ad shows time range, tagger attribution, and "Ad" badge
  4. 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
  5. 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
  6. Skip Guards:

    • Scrub backward into a skipped ad
    • Ad should be skipped again (once per re-entry)
    • No infinite skip loops

6. Run Tests

npm test

Tests 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.)

Key Features

✅ Clean Separation of Chapters and Ads

Chapters and ads are maintained as separate arrays:

  • player.chapters[] - navigational markers
  • adSkipManager.mergedAdSegments[] - ad intervals

Chapter navigation NEVER considers ads. Ads NEVER split chapters.

✅ Robust Episode Identity

Episodes are uniquely identified even when:

  • Different apps use different episode IDs
  • RSS GUID is missing or unreliable
  • Episodes are syndicated across multiple feeds

✅ Automatic Merge/Dedup

Overlapping ad segments from multiple taggers are automatically merged:

Tagger A: [10s - 30s]
Tagger B: [25s - 40s]
Result:   [10s - 40s]  (merged, both taggers credited)

✅ Skip Guards

Sophisticated guards prevent skip loops:

  • Debounce window (1 second)
  • Track last skipped segment
  • Allow re-skip on re-entry (user scrubs backward)

✅ User Control

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

✅ Tagger Attribution

Every ad segment shows who tagged it:

  • Avatar (HTTPS:// URLs only)
  • Display name (or shortened pubkey)
  • Optional reputation score (future)

✅ Swappable Providers

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 = nostrAdSegmentProvider

Testing Scenarios

Scenario 1: Ad Inside a Chapter ✅

Chapter: [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

Scenario 2: Overlapping Ads Merged ✅

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

Scenario 3: Ad Touching Chapter Boundary ✅

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

Scenario 4: Invalid Ad Dropped ✅

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

Scenario 5: Scrub Backward Re-Triggers Skip ✅

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

Future Enhancements (See NOSTR_INTEGRATION.md)

  • Replace FixtureAdSegmentProvider with NostrAdSegmentProvider
  • 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

Troubleshooting

Ad segments not loading

  1. Check console for errors
  2. Verify fixture file exists at src/lib/fixtures/adSegments/<episodeKey>.json
  3. Verify episodeKey matches (check console log for generated key)
  4. Verify JSON format is valid

Skip not working

  1. Verify "Skip Ads" toggle is ON (check icon state)
  2. Check console for skip logs: [AdSkip] Skipping ad segment...
  3. Verify ad segment has valid startTime/endTime
  4. Check that adSegments were merged (console log shows merged count)
  5. Verify player is using audio (not video) - skip logic is in PlayerAPIAudio.tsx

Tests failing

  1. Run npm install to ensure dependencies are up to date
  2. Check TypeScript compilation: npx tsc --noEmit
  3. Run individual test file: npm test -- adSegmentMerge.test.ts
  4. Check that crypto module is available (Node.js built-in)

Dynamic imports not working

The fixture loader uses dynamic imports:

await import(`~/lib/fixtures/adSegments/${filename}.json`)

If this fails:

  1. Ensure fixture files are in src/lib/fixtures/ (not public/)
  2. Check Next.js version supports dynamic JSON imports
  3. Verify file paths use / (not \) even on Windows

Architecture Decisions

Why Separate Chapters and Ads?

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)

Why Half-Open Intervals [startTime, endTime)?

Standard in computer science:

  • Simplifies adjacent interval logic
  • Prevents double-counting boundaries
  • Matches most media player seek behavior

Why Binary Search for findAdSegmentAtTime()?

  • O(log n) vs O(n) for linear search
  • Matters when episodes have dozens of ads
  • Merged segments are already sorted

Why Provider Interface Pattern?

  • 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)

Credits

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

License

This POC inherits the license of the Podverse project (AGPL-3.0).

Contact

For questions about this POC, refer to the Podverse community channels or create an issue in the repository.


Happy Ad-Skipping! 🎧⚡