This document outlines the integration points for replacing the POC fixture-based system with real Nostr relays, payments, and caching.
The POC uses a provider interface pattern that allows swapping implementations without refactoring:
interface AdSegmentProvider {
fetchAdSegments(episodeKey: string): Promise<AdSegment[]>
fetchTaggerProfile(pubkey: string): Promise<TaggerProfile | null>
fetchEpisodeAdData?(episodeKey: string): Promise<EpisodeAdData | null>
}
// POC: FixtureAdSegmentProvider (reads local JSON)
// Future: NostrAdSegmentProvider (queries Nostr relays)File: src/services/adSegments/adSegmentNostrProvider.ts (to be created)
Implementation:
import { AdSegmentProvider, AdSegment, TaggerProfile } from './adSegmentTypes'
import { relayPool } from '~/lib/nostr/relayPool' // To be implemented
export class NostrAdSegmentProvider implements AdSegmentProvider {
private relayUrls: string[]
private cache: Map<string, AdSegment[]>
constructor(relayUrls: string[]) {
this.relayUrls = relayUrls
this.cache = new Map()
}
async fetchAdSegments(episodeKey: string): Promise<AdSegment[]> {
// Query Nostr relays for ad segment events
// Filter by episodeKey
// Parse events into AdSegment objects
// Cache results
}
async fetchTaggerProfile(pubkey: string): Promise<TaggerProfile | null> {
// Query Nostr for NIP-01 kind 0 metadata event
// Parse into TaggerProfile
// Cache results
}
}Nostr Event Schema (Custom NIP):
Define a custom Nostr event kind for ad segments:
{
"kind": 30XX, // TBD: Choose a custom kind number
"pubkey": "<tagger's nostr pubkey>",
"created_at": <unix timestamp>,
"tags": [
["e", "<episodeKey>"],
["podcast", "<podcast feed URL>"],
["episode", "<episode enclosure URL>"],
["start", "<startTime in seconds>"],
["end", "<endTime in seconds>"],
["type", "ad"],
["description", "Pre-roll sponsor read"]
],
"content": "Optional human-readable description or notes",
"sig": "<signature>"
}Relay Queries:
// Fetch all ad segments for an episode
const filter = {
kinds: [30XX],
"#e": [episodeKey],
limit: 100
}
// Fetch tagger profile
const profileFilter = {
kinds: [0], // NIP-01 metadata
authors: [pubkey],
limit: 1
}File: src/lib/nostr/relayPool.ts (to be created)
Functionality:
- Connect to multiple Nostr relays
- Pool connections for efficiency
- Handle WebSocket reconnection logic
- Implement subscription management
- Aggregate results from multiple relays
Libraries:
nostr-tools(official Nostr TypeScript library)nostr-relay-pool(community relay pool implementation)
Example:
import { SimplePool } from 'nostr-tools/pool'
const pool = new SimplePool()
const relays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
]
export async function fetchAdSegmentEvents(episodeKey: string) {
const events = await pool.list(relays, [{
kinds: [30XX],
"#e": [episodeKey]
}])
return events
}File: src/services/adSegments/adSegmentRealtimeUpdates.ts (to be created)
Functionality:
- Subscribe to ad segment events for current episode
- Update player state when new ad segments are published
- Re-merge segments when updates arrive
- Gracefully handle subscription errors
Example:
export function subscribeToAdSegmentUpdates(
episodeKey: string,
onUpdate: (newSegments: AdSegment[]) => void
) {
const sub = pool.sub(relays, [{
kinds: [30XX],
"#e": [episodeKey]
}])
sub.on('event', (event) => {
const newSegment = parseAdSegmentEvent(event)
// Trigger re-fetch and re-merge
onUpdate([...existingSegments, newSegment])
})
return () => sub.unsub() // Cleanup function
}File: src/services/adSegments/adSegmentValidation.ts (to be created)
Concept:
- Users can "zap" (send sats via Lightning) ad segments to vote on accuracy
- Zaps increase confidence score and tagger reputation
- High-zapped segments take precedence in merging conflicts
- Low-zapped segments may be filtered out
Implementation:
// Fetch zaps for an ad segment event
async function fetchZapsForSegment(eventId: string): Promise<Zap[]> {
const zapEvents = await pool.list(relays, [{
kinds: [9735], // NIP-57 zap receipt
"#e": [eventId]
}])
return zapEvents.map(parseZapEvent)
}
// Calculate confidence based on zaps
function calculateConfidence(segment: AdSegment, zaps: Zap[]): number {
const totalSats = zaps.reduce((sum, zap) => sum + zap.amount, 0)
// Map sats to confidence score (0-100)
return Math.min(100, totalSats / 1000) // 1000 sats = 100% confidence
}
// Weighted merging: prefer segments with higher zap totals
function mergeWithWeights(segments: AdSegment[], zaps: Map<string, Zap[]>) {
// Sort by confidence before merging
const sorted = segments.sort((a, b) => {
const aConfidence = calculateConfidence(a, zaps.get(a.id) || [])
const bConfidence = calculateConfidence(b, zaps.get(b.id) || [])
return bConfidence - aConfidence
})
return mergeAdSegments(sorted)
}File: src/services/adSegments/adSegmentCache.ts (to be created)
Strategies:
-
In-Memory Cache (current player session):
- Already implemented in
FixtureAdSegmentProvider - Keep for active episode
- Already implemented in
-
IndexedDB Cache (persistent browser storage):
import { openDB } from 'idb' const db = await openDB('podverse-ad-segments', 1, { upgrade(db) { db.createObjectStore('adSegments', { keyPath: 'episodeKey' }) db.createObjectStore('profiles', { keyPath: 'pubkey' }) } }) // Store ad segments await db.put('adSegments', { episodeKey, segments, cachedAt: Date.now() }) // Retrieve with TTL check const cached = await db.get('adSegments', episodeKey) if (cached && Date.now() - cached.cachedAt < 3600000) { // 1 hour TTL return cached.segments }
-
Server-Side Cache (optional, for scaling):
- Cache popular episodes on Podverse API server
- Serve from cache if available, fall back to relay query
- Periodically refresh from relays
File: src/components/AdSegments/AdSegmentTagger.tsx (to be created)
Functionality:
- UI for users to mark ad start/end times during playback
- Publish ad segment event to Nostr relays
- Optionally: request zaps from other users for validation
Workflow:
- User presses "Mark Ad Start" button
- Current playback time recorded
- User presses "Mark Ad End" button
- Publish ad segment event to relays:
import { getEventHash, signEvent } from 'nostr-tools'
async function publishAdSegment(
episodeKey: string,
startTime: number,
endTime: number,
description: string,
userKeys: { pubkey: string; privkey: string }
) {
const event = {
kind: 30XX,
pubkey: userKeys.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["e", episodeKey],
["start", startTime.toString()],
["end", endTime.toString()],
["type", "ad"],
["description", description]
],
content: description
}
event.id = getEventHash(event)
event.sig = signEvent(event, userKeys.privkey)
// Publish to relays
await pool.publish(relays, event)
}File: src/services/adSegments/adSegmentConflictResolution.ts (to be created)
Scenarios:
-
Multiple taggers mark different times for same ad:
- Use weighted average based on reputation/zaps
- Or use majority vote (most common time range)
-
User reports false positive:
- Publish negative vote event (custom kind)
- Decrease confidence score for that segment
-
Spam/malicious tagging:
- Filter out segments from low-reputation taggers
- Require minimum zap threshold for segments to appear
Example:
function resolveConflicts(segments: AdSegment[], votes: Vote[]): AdSegment[] {
// Group overlapping segments
const groups = groupOverlapping(segments)
// For each group, choose the best segment based on:
// 1. Highest zap total
// 2. Highest reputation
// 3. Most votes
return groups.map(group => {
return group.sort((a, b) => {
const scoreA = calculateScore(a, votes)
const scoreB = calculateScore(b, votes)
return scoreB - scoreA
})[0]
})
}File: src/lib/nostr/auth.ts (to be created)
Functionality:
- Integrate with browser Nostr extensions (Alby, nos2x, Flamingo)
- Request user's public key
- Sign events using extension
Example:
// Check if Nostr extension is available
if (window.nostr) {
// Get user's public key
const pubkey = await window.nostr.getPublicKey()
// Sign ad segment event
const signedEvent = await window.nostr.signEvent(unsignedEvent)
// Publish to relays
await pool.publish(relays, signedEvent)
}- Create
NostrAdSegmentProviderclass - Implement relay queries
- Add caching layer
Replace in src/services/player/player.tsx:
// POC (current)
import { fixtureAdSegmentProvider } from '../adSegments/adSegmentFixtureProvider'
const provider = fixtureAdSegmentProvider
// Production (future)
import { nostrAdSegmentProvider } from '../adSegments/adSegmentNostrProvider'
const provider = nostrAdSegmentProviderOr use environment variable:
const provider = process.env.NEXT_PUBLIC_USE_NOSTR === 'true'
? nostrAdSegmentProvider
: fixtureAdSegmentProvider- Implement "Mark Ad" button in player
- Implement ad segment submission form
- Integrate with Nostr signing
- Query zaps for ad segments
- Calculate confidence scores
- Display reputation in UI
- Subscribe to relay events during playback
- Update ad segments dynamically
- Handle WebSocket errors gracefully
- Mock Nostr relay responses
- Test event parsing
- Test conflict resolution logic
- Connect to test relay
- Publish test events
- Verify fetching and parsing
- Full user workflow: mark ad → publish → fetch → skip
- Test with multiple users tagging same episode
- Verify zap-based validation
-
Event Validation:
- Verify event signatures
- Check event timestamps (reject too old or future events)
- Validate tag formats
-
Spam Protection:
- Rate limit queries to relays
- Filter out segments from blocked users
- Require minimum reputation for first-time taggers
-
Privacy:
- Ad segment events are public (by design)
- Users can use pseudonymous Nostr keys
- No personal data required
Public Relays:
wss://relay.damus.io(popular, well-maintained)wss://relay.nostr.band(indexing relay)wss://nos.lol(reliable)wss://relay.snort.social(high uptime)
Specialized Relays (Future):
- Create dedicated relay for podcast ad segments
- Better performance and lower latency
- Custom filtering and indexing
- Custom NIP Number: Should we propose a new NIP for podcast ad segments, or use an existing kind?
- Zap Threshold: What minimum zap amount should be required for high confidence?
- Relay Selection: Should users choose their own relays, or use a default set?
- Offline Mode: How to handle when relays are unavailable (fallback to cached data)?
Search for // TODO: Future Nostr Integration Points in these files:
src/services/adSegments/adSegmentTypes.tssrc/services/adSegments/adSegmentIdentity.tssrc/services/adSegments/adSegmentMerge.tssrc/services/adSegments/adSegmentFixtureProvider.tssrc/services/player/playerAdSkip.ts
These markers indicate where Nostr-specific logic should be added.