-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathadSegmentIdentity.ts
More file actions
233 lines (207 loc) · 6.72 KB
/
Copy pathadSegmentIdentity.ts
File metadata and controls
233 lines (207 loc) · 6.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
/**
* Episode Identity Resolution for Ad Segment Lookup
*
* Generates a stable, unique episodeKey for looking up ad segments.
*
* PRIMARY METHOD: RSS <item><guid> (exact string, trimmed)
* FALLBACK METHOD: hash(feedUrl + enclosureUrl [+ pubDate])
*
* This ensures ad segments can be looked up even if:
* - Different podcast apps use different episode IDs
* - Episode GUID is missing or unreliable
* - Episode is syndicated across multiple feeds
*/
import crypto from 'crypto'
/**
* Episode Identity Input
*
* Minimal data needed to generate an episode key.
* Can be extracted from Podverse Episode objects, RSS feed items, or NowPlayingItem.
*/
export interface EpisodeIdentityInput {
/** RSS <guid> element (preferred) */
guid?: string | null
/** Podcast feed URL (for fallback hash) */
feedUrl?: string | null
/** Episode media/enclosure URL (for fallback hash) */
enclosureUrl?: string | null
mediaUrl?: string | null // Alternative field name
/** Episode publication date (for fallback hash, optional) */
pubDate?: string | null
}
/**
* Normalize GUID
*
* Handles various GUID formats found in RSS feeds:
* - Plain text: "ec8b4655-cb61-4d69-87ee-dbdba77c0a62"
* - CDATA wrapped: "<![CDATA[ b47746b8-daa3-11f0-b08a-233a79f7667c ]]>"
* - With whitespace
*
* @param guid Raw GUID string from RSS feed
* @returns Normalized GUID (trimmed, CDATA removed) or null if empty
*/
function normalizeGuid(guid?: string | null): string | null {
if (!guid) {
return null
}
// Trim whitespace
let normalized = guid.trim()
// Remove CDATA wrapper if present
// Pattern: <![CDATA[ ... ]]>
const cdataPattern = /^<!\[CDATA\[([\s\S]*?)\]\]>$/
const cdataMatch = normalized.match(cdataPattern)
if (cdataMatch) {
normalized = cdataMatch[1].trim()
}
// Validate that we have a non-empty GUID after normalization
if (normalized.length === 0) {
return null
}
return normalized
}
/**
* Generate Episode Key
*
* Primary identifier for looking up ad segments.
*
* Strategy:
* 1. If GUID exists and is non-empty (after trim), use it as-is
* 2. Otherwise, generate hash from feedUrl + enclosureUrl (+ pubDate if available)
*
* @param input Episode identity data
* @returns Stable episode key string
* @throws Error if neither GUID nor (feedUrl + enclosureUrl) are available
*/
export function generateEpisodeKey(input: EpisodeIdentityInput): string {
// Normalize input (including CDATA stripping)
const guid = normalizeGuid(input.guid)
const feedUrl = input.feedUrl?.trim()
const enclosureUrl = (input.enclosureUrl || input.mediaUrl)?.trim()
const pubDate = input.pubDate?.trim()
// Strategy 1: Use GUID if available
if (guid && guid.length > 0) {
return guid
}
// Strategy 2: Generate hash from feed + enclosure + pubDate
if (!feedUrl || !enclosureUrl) {
throw new Error(
'Cannot generate episode key: missing both GUID and (feedUrl + enclosureUrl). ' +
`Got: guid=${guid}, feedUrl=${feedUrl}, enclosureUrl=${enclosureUrl}`
)
}
return generateFallbackHash(feedUrl, enclosureUrl, pubDate)
}
/**
* Generate Fallback Hash
*
* Creates a deterministic hash from feed + enclosure + pubDate.
* Uses SHA-256 and returns first 16 hex chars (64 bits) for readability.
*
* Format: "hash:{first-16-chars}"
*
* @param feedUrl Podcast feed URL
* @param enclosureUrl Episode media URL
* @param pubDate Optional publication date (ISO 8601 string)
* @returns Hash-based episode key
*/
function generateFallbackHash(feedUrl: string, enclosureUrl: string, pubDate?: string | null): string {
// Normalize URLs (lowercase, trim, remove trailing slash)
const normalizedFeed = normalizeUrl(feedUrl)
const normalizedEnclosure = normalizeUrl(enclosureUrl)
// Build hash input
let hashInput = `${normalizedFeed}|${normalizedEnclosure}`
if (pubDate) {
hashInput += `|${pubDate}`
}
// Generate SHA-256 hash
const hash = crypto.createHash('sha256').update(hashInput, 'utf8').digest('hex')
// Return first 16 chars (64 bits) with prefix
return `hash:${hash.substring(0, 16)}`
}
/**
* Normalize URL for hashing
*
* - Convert to lowercase
* - Trim whitespace
* - Remove trailing slash
* - Preserve protocol and query params (important for enclosure URLs with tokens)
*
* @param url URL to normalize
* @returns Normalized URL string
*/
function normalizeUrl(url: string): string {
let normalized = url.trim().toLowerCase()
// Remove trailing slash (but preserve if it's just the protocol)
if (normalized.endsWith('/') && normalized.length > 8) {
// Don't strip if it's just "https://"
normalized = normalized.slice(0, -1)
}
return normalized
}
/**
* Check if Episode Key is Hash-Based
*
* Returns true if the key was generated via fallback hash (starts with "hash:")
*
* @param episodeKey Episode key to check
* @returns True if hash-based, false if GUID-based
*/
export function isHashBasedKey(episodeKey: string): boolean {
return episodeKey.startsWith('hash:')
}
/**
* Extract Episode Key from Podverse Episode Object
*
* Helper to extract episode key from Podverse's Episode type.
* Podverse stores episode.guid and episode.podcast.feedUrl.
*
* @param episode Podverse Episode object (or partial)
* @returns Episode key string
*/
export function extractEpisodeKeyFromPodverseEpisode(episode: any): string {
return generateEpisodeKey({
guid: episode.guid,
feedUrl: episode.podcast?.feedUrl || episode.feedUrl,
enclosureUrl: episode.mediaUrl,
pubDate: episode.pubDate,
})
}
/**
* Extract Episode Key from NowPlayingItem
*
* Helper to extract episode key from Podverse's NowPlayingItem type.
*
* @param item NowPlayingItem object
* @returns Episode key string
*/
export function extractEpisodeKeyFromNowPlayingItem(item: any): string {
return generateEpisodeKey({
guid: item.episodeGuid,
feedUrl: item.podcastFeedUrl,
enclosureUrl: item.episodeMediaUrl,
pubDate: item.episodePubDate,
})
}
/**
* Batch Generate Episode Keys
*
* Generate keys for multiple episodes at once.
* Useful for pre-caching or bulk operations.
*
* @param inputs Array of episode identity inputs
* @returns Array of episode keys (same order as inputs)
*/
export function batchGenerateEpisodeKeys(inputs: EpisodeIdentityInput[]): string[] {
return inputs.map((input) => {
try {
return generateEpisodeKey(input)
} catch (error) {
console.error('Failed to generate episode key:', error, input)
return '' // Return empty string for failed keys
}
})
}
// TODO: Future Nostr Integration Points
// - Add support for Podcasting 2.0 GUID namespace (podcast:guid with namespace attribute)
// - Add support for Nostr event IDs (NIP-XX for podcast episodes)
// - Consider adding V4V split-based episode identity (for dynamic content)