Skip to content

Commit 25aef1b

Browse files
committed
improve: enhance youtub HLS handling
1 parent 4816e44 commit 25aef1b

4 files changed

Lines changed: 235 additions & 404 deletions

File tree

src/playback/hls/HLSHandler.js

Lines changed: 173 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,37 @@ export default class HLSHandler extends PassThrough {
77
constructor(url, options = {}) {
88
super({ highWaterMark: options.highWaterMark || 1024 * 1024 * 5 })
99

10+
this.masterUrl = url
1011
this.currentUrl = url
1112
this.headers = options.headers || {}
1213
this.localAddress = options.localAddress || null
14+
this.onResolveUrl = options.onResolveUrl || null
1315
this.strategy = options.type?.includes('fmp4') ? 'segmented' : 'streaming'
1416

1517
this.fetcher = new SegmentFetcher({
1618
headers: this.headers,
17-
localAddress: this.localAddress
19+
localAddress: this.localAddress,
20+
onResolveUrl: this.onResolveUrl
1821
})
1922

2023
this.processedSegments = new Set()
2124
this.processedOrder = []
22-
this.MAX_HISTORY = 100
25+
this.MAX_HISTORY = 200
2326
this.segmentQueue = []
2427
this.isFetching = false
2528
this.stop = false
2629
this.lastMapUri = null
2730
this.isLive = false
2831
this.playlistTimer = null
2932
this.activeSegmentStream = null
33+
this.lastMediaSequence = -1
34+
this.highestSequence = -1
35+
this.maxGap = 30
36+
this.stuckCount = 0
37+
this.preRolled = false
38+
this.justResynced = false
39+
this.masterRefreshCounter = 0
40+
this.MASTER_REFRESH_INTERVAL = 3
3041

3142
this.on('error', (err) => { this.destroy(err) })
3243
this._start()
@@ -50,13 +61,15 @@ export default class HLSHandler extends PassThrough {
5061
this.segmentQueue = []
5162
this.processedSegments.clear()
5263
this.processedOrder = []
64+
this.lastMediaSequence = -1
65+
this.highestSequence = -1
5366
if (!this.destroyed) super.destroy(err)
5467
}
5568

56-
_rememberSegment(url) {
57-
if (this.processedSegments.has(url)) return false
58-
this.processedSegments.add(url)
59-
this.processedOrder.push(url)
69+
_rememberSegment(key) {
70+
if (this.processedSegments.has(key)) return false
71+
this.processedSegments.add(key)
72+
this.processedOrder.push(key)
6073
if (this.processedOrder.length > this.MAX_HISTORY) {
6174
this.processedSegments.delete(this.processedOrder.shift())
6275
}
@@ -69,50 +82,174 @@ export default class HLSHandler extends PassThrough {
6982
const { body: playlistContent, error, statusCode } = await http1makeRequest(this.currentUrl, {
7083
headers: this.headers, method: 'GET', localAddress: this.localAddress
7184
})
72-
if (error || statusCode !== 200) throw new Error(`Playlist fetch failed: ${statusCode}`)
7385

74-
const parsed = PlaylistParser.parse(playlistContent, this.currentUrl)
86+
if (error || statusCode !== 200) {
87+
if (statusCode === 403 || statusCode === 410) {
88+
if (this.currentUrl !== this.masterUrl) {
89+
this.currentUrl = this.masterUrl
90+
this.justResynced = true
91+
return setImmediate(() => this._playlistLoop())
92+
}
93+
}
94+
throw new Error(`Playlist fetch failed: ${statusCode}`)
95+
}
96+
97+
let parsed
98+
try {
99+
parsed = PlaylistParser.parse(playlistContent, this.currentUrl)
100+
} catch (e) {
101+
if (this.currentUrl !== this.masterUrl) {
102+
this.currentUrl = this.masterUrl
103+
this.justResynced = true
104+
return setImmediate(() => this._playlistLoop())
105+
}
106+
throw e
107+
}
108+
75109
if (parsed.isMaster) {
76-
this.currentUrl = parsed.variants[0].url
110+
const bestVariant = parsed.variants.reduce((prev, current) => {
111+
const hasAudio = (v) => v.codecs?.includes('mp4a') || v.codecs?.includes('opus')
112+
if (!hasAudio(prev)) return current
113+
if (!hasAudio(current)) return prev
114+
return (current.bandwidth > prev.bandwidth) ? current : prev
115+
}, parsed.variants[0])
116+
117+
const itag = bestVariant.url.match(/[\/&]itag[\/](\d+)/)?.[1] ||
118+
bestVariant.url.match(/[?&]itag=(\d+)/)?.[1] || 'unknown'
119+
120+
logger('debug', 'HLSHandler', `Selected variant itag: ${itag}, bandwidth: ${bestVariant.bandwidth}, codecs: ${bestVariant.codecs}`)
121+
this.currentUrl = bestVariant.url
77122
return setImmediate(() => this._playlistLoop())
78123
}
79124

80-
const isFirstLoad = !this.isLive && this.processedSegments.size === 0
81125
this.isLive = parsed.isLive
82126

83-
if (isFirstLoad && this.isLive) {
84-
const toSkip = Math.max(0, parsed.segments.length - 3)
85-
for (let i = 0; i < toSkip; i++) {
86-
this.processedSegments.add(parsed.segments[i].url)
87-
this.processedOrder.push(parsed.segments[i].url)
127+
if (this.lastMediaSequence !== -1 && (parsed.mediaSequence < this.lastMediaSequence || parsed.mediaSequence > this.lastMediaSequence + this.maxGap)) {
128+
logger('warn', 'HLSHandler', `Playlist sequence discontinuity (${this.lastMediaSequence} -> ${parsed.mediaSequence}). Resetting to live edge.`)
129+
this.segmentQueue = []
130+
this.processedSegments.clear()
131+
this.processedOrder = []
132+
this.highestSequence = -1
133+
this.preRolled = false
134+
this.justResynced = true
135+
}
136+
this.lastMediaSequence = parsed.mediaSequence
137+
138+
if (this.isLive && ++this.masterRefreshCounter >= this.MASTER_REFRESH_INTERVAL) {
139+
this.masterRefreshCounter = 0
140+
this.currentUrl = this.masterUrl
141+
return setImmediate(() => this._playlistLoop())
142+
}
143+
144+
const isFirstLoad = this.processedSegments.size === 0
145+
if ((isFirstLoad || this.justResynced) && this.isLive) {
146+
if (this.justResynced) {
147+
this.processedSegments.clear()
148+
this.processedOrder = []
149+
this.highestSequence = -1
150+
}
151+
152+
const toTake = 12
153+
const startIdx = Math.max(0, parsed.segments.length - toTake)
154+
for (let i = 0; i < startIdx; i++) {
155+
const seg = parsed.segments[i]
156+
const key = seg.sequence !== -1 ? seg.sequence : seg.url
157+
this.processedSegments.add(key)
158+
this.processedOrder.push(key)
159+
if (seg.sequence !== -1 && seg.sequence > this.highestSequence) this.highestSequence = seg.sequence
88160
}
161+
this.justResynced = false
89162
}
90163

91-
const newSegments = parsed.segments.filter(s => !this.processedSegments.has(s.url))
92-
for (const segment of newSegments) {
93-
this._rememberSegment(segment.url)
94-
this.segmentQueue.push(segment)
164+
const newSegments = parsed.segments.filter(s => {
165+
if (s.sequence !== -1 && s.sequence <= this.highestSequence) return false
166+
const key = s.sequence !== -1 ? s.sequence : s.url
167+
return !this.processedSegments.has(key)
168+
})
169+
170+
if (newSegments.length > 0) {
171+
this.stuckCount = 0
172+
for (const segment of newSegments) {
173+
if (segment.discontinuity && this.isLive) {
174+
logger('debug', 'HLSHandler', 'Discontinuity detected in segment. Clearing queue and re-syncing.')
175+
this.segmentQueue = []
176+
this.processedSegments.clear()
177+
this.processedOrder = []
178+
this.highestSequence = -1
179+
this.preRolled = false
180+
this.justResynced = true
181+
return setImmediate(() => this._playlistLoop())
182+
}
183+
184+
const key = segment.sequence !== -1 ? segment.sequence : segment.url
185+
this._rememberSegment(key)
186+
this.segmentQueue.push(segment)
187+
if (segment.sequence !== -1 && segment.sequence > this.highestSequence) this.highestSequence = segment.sequence
188+
}
189+
} else if (this.isLive) {
190+
if (++this.stuckCount >= 10) {
191+
logger('warn', 'HLSHandler', 'No new segments for 10 reloads. Refreshing master playlist.')
192+
this.stuckCount = 0
193+
this.currentUrl = this.masterUrl
194+
this.justResynced = true
195+
return setImmediate(() => this._playlistLoop())
196+
}
95197
}
96198

97-
if (this.segmentQueue.length > 0 && !this.isFetching) this._fetchSegments()
199+
if (this.segmentQueue.length > 0) {
200+
if (!this.isFetching) this._fetchSegments()
201+
}
98202

99203
if (this.isLive && !playlistContent.includes('#EXT-X-ENDLIST')) {
100-
const delay = Math.max(1, parsed.targetDuration) * 1000
101-
this.playlistTimer = setTimeout(() => this._playlistLoop(), delay)
204+
this._scheduleNextTick(parsed.targetDuration)
102205
}
103206
} catch (err) {
104207
if (!this.isLive) return this.destroy(err)
105-
this.playlistTimer = setTimeout(() => this._playlistLoop(), 5000)
208+
this.playlistTimer = setTimeout(() => this._playlistLoop(), 3000)
106209
}
107210
}
108211

109212
async _fetchSegments() {
110213
if (this.isFetching || this.stop) return
111214
this.isFetching = true
112215

113-
while (this.segmentQueue.length > 0 && !this.stop) {
114-
const segment = this.segmentQueue.shift()
216+
let nextSegmentPromise = null
217+
218+
const getSegment = async (seg) => {
115219
try {
220+
if (this.strategy === 'segmented') {
221+
return { segment: seg, data: await this.fetcher.fetchSegment(seg, { stream: false }) }
222+
}
223+
return { segment: seg, stream: await this.fetcher.fetchSegment(seg, { stream: true }) }
224+
} catch (err) {
225+
logger('error', 'HLSHandler', `Segment fetch error ${seg.sequence}: ${err.message}`)
226+
return null
227+
}
228+
}
229+
230+
while ((this.segmentQueue.length > 0 || nextSegmentPromise) && !this.stop) {
231+
if (this.isLive && this.segmentQueue.length < 3 && !nextSegmentPromise) {
232+
await new Promise(r => setTimeout(r, 500))
233+
if (this.segmentQueue.length === 0) break
234+
}
235+
236+
let current
237+
if (nextSegmentPromise) {
238+
current = await nextSegmentPromise
239+
nextSegmentPromise = null
240+
} else {
241+
const seg = this.segmentQueue.shift()
242+
current = await getSegment(seg)
243+
}
244+
245+
if (!current) continue
246+
247+
if (this.segmentQueue.length > 0 && !this.stop) {
248+
nextSegmentPromise = getSegment(this.segmentQueue.shift())
249+
}
250+
251+
try {
252+
const { segment, data, stream } = current
116253
if (segment.map && segment.map.uri !== this.lastMapUri) {
117254
const mapData = await this.fetcher.fetchMap(segment.map, segment.key)
118255
if (mapData && !this.stop) {
@@ -122,22 +259,19 @@ export default class HLSHandler extends PassThrough {
122259
}
123260

124261
if (this.strategy === 'segmented') {
125-
const data = await this.fetcher.fetchSegment(segment, { stream: false })
126-
if (!this.stop) {
262+
if (!this.stop && data) {
127263
if (!this.write(data)) await new Promise(r => this.once('drain', r))
128264
}
129-
} else {
130-
const segmentStream = await this.fetcher.fetchSegment(segment, { stream: true })
131-
this.activeSegmentStream = segmentStream
132-
133-
for await (const chunk of segmentStream) {
265+
} else if (stream) {
266+
this.activeSegmentStream = stream
267+
for await (const chunk of stream) {
134268
if (this.stop) break
135269
if (!this.write(chunk)) await new Promise(r => this.once('drain', r))
136270
}
137271
this.activeSegmentStream = null
138272
}
139273
} catch (err) {
140-
logger('error', 'HLSHandler', `Segment error ${segment.sequence}: ${err.message}`)
274+
logger('error', 'HLSHandler', `Segment processing error: ${err.message}`)
141275
}
142276
}
143277

@@ -147,4 +281,10 @@ export default class HLSHandler extends PassThrough {
147281
this.end()
148282
}
149283
}
284+
285+
_scheduleNextTick(targetDuration) {
286+
if (this.stop) return
287+
const delay = Math.max(0.5, targetDuration / 2) * 1000
288+
this.playlistTimer = setTimeout(() => this._playlistLoop(), delay)
289+
}
150290
}

src/playback/hls/PlaylistParser.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { logger } from '../../utils.js'
22

33
export default class PlaylistParser {
44
static parse(content, baseUrl) {
5+
if (!content.includes('#EXT')) {
6+
throw new Error('Invalid HLS playlist format')
7+
}
8+
59
const lines = content.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
610

711
if (lines.some(l => l.startsWith('#EXT-X-STREAM-INF'))) {
@@ -31,10 +35,13 @@ export default class PlaylistParser {
3135
}
3236

3337
let segmentIndex = 0
38+
let pendingDiscontinuity = false
3439
for (let i = 0; i < lines.length; i++) {
3540
const line = lines[i]
3641

37-
if (line.startsWith('#EXT-X-KEY:')) {
42+
if (line.startsWith('#EXT-X-DISCONTINUITY')) {
43+
pendingDiscontinuity = true
44+
} else if (line.startsWith('#EXT-X-KEY:')) {
3845
currentKey = this.parseAttributes(line, baseUrl)
3946
} else if (line.startsWith('#EXT-X-MAP:')) {
4047
currentMap = this.parseAttributes(line, baseUrl)
@@ -56,10 +63,12 @@ export default class PlaylistParser {
5663
key: currentKey,
5764
map: currentMap,
5865
byteRange: lastByteRange,
59-
sequence: mediaSequence + segmentIndex
66+
sequence: mediaSequence + segmentIndex,
67+
discontinuity: pendingDiscontinuity
6068
})
6169
segmentIndex++
6270
lastByteRange = null
71+
pendingDiscontinuity = false
6372
i = j
6473
}
6574
}
@@ -79,7 +88,8 @@ export default class PlaylistParser {
7988
const attrs = this.parseAttributes(attrLine, baseUrl)
8089
variants.push({
8190
url: new URL(urlLine, baseUrl).toString(),
82-
bandwidth: parseInt(attrs.bandwidth || 0, 10)
91+
bandwidth: parseInt(attrs.bandwidth || 0, 10),
92+
codecs: attrs.codecs || ''
8393
})
8494
}
8595
}

0 commit comments

Comments
 (0)