Skip to content

Commit e6ed545

Browse files
committed
improve: refactor HLS handling in Gaana and SoundCloud sources
1 parent 25aef1b commit e6ed545

3 files changed

Lines changed: 109 additions & 197 deletions

File tree

src/playback/hls/HLSHandler.js

Lines changed: 74 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default class HLSHandler extends PassThrough {
1212
this.headers = options.headers || {}
1313
this.localAddress = options.localAddress || null
1414
this.onResolveUrl = options.onResolveUrl || null
15-
this.strategy = options.type?.includes('fmp4') ? 'segmented' : 'streaming'
15+
this.strategy = options.strategy || (options.type?.includes('fmp4') ? 'segmented' : 'streaming')
1616

1717
this.fetcher = new SegmentFetcher({
1818
headers: this.headers,
@@ -24,12 +24,13 @@ export default class HLSHandler extends PassThrough {
2424
this.processedOrder = []
2525
this.MAX_HISTORY = 200
2626
this.segmentQueue = []
27+
this.MAX_PARALLEL_FETCHES = this.strategy === 'segmented' ? 3 : (this.strategy === 'streaming' ? 2 : 1)
2728
this.isFetching = false
2829
this.stop = false
2930
this.lastMapUri = null
3031
this.isLive = false
3132
this.playlistTimer = null
32-
this.activeSegmentStream = null
33+
this.activeSegmentStreams = new Map()
3334
this.lastMediaSequence = -1
3435
this.highestSequence = -1
3536
this.maxGap = 30
@@ -54,10 +55,10 @@ export default class HLSHandler extends PassThrough {
5455
clearTimeout(this.playlistTimer)
5556
this.playlistTimer = null
5657
}
57-
if (this.activeSegmentStream) {
58-
this.activeSegmentStream.destroy()
59-
this.activeSegmentStream = null
58+
for (const stream of this.activeSegmentStreams.values()) {
59+
stream.destroy()
6060
}
61+
this.activeSegmentStreams.clear()
6162
this.segmentQueue = []
6263
this.processedSegments.clear()
6364
this.processedOrder = []
@@ -107,31 +108,31 @@ export default class HLSHandler extends PassThrough {
107108
}
108109

109110
if (parsed.isMaster) {
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}`)
111+
const sortedVariants = parsed.variants.sort((a, b) => b.bandwidth - a.bandwidth)
112+
const bestVariant = sortedVariants.find(v =>
113+
(v.codecs?.includes('mp4a') || v.codecs?.includes('opus')) &&
114+
!v.codecs?.includes('avc1')
115+
) || sortedVariants.find(v =>
116+
v.codecs?.includes('mp4a') || v.codecs?.includes('opus')
117+
) || sortedVariants[0]
118+
119+
logger('debug', 'HLSHandler', `Selected variant bandwidth: ${bestVariant.bandwidth}, codecs: ${bestVariant.codecs}`)
121120
this.currentUrl = bestVariant.url
122121
return setImmediate(() => this._playlistLoop())
123122
}
124123

125124
this.isLive = parsed.isLive
126125

127126
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
127+
if (this.isLive) {
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+
}
135136
}
136137
this.lastMediaSequence = parsed.mediaSequence
137138

@@ -142,7 +143,7 @@ export default class HLSHandler extends PassThrough {
142143
}
143144

144145
const isFirstLoad = this.processedSegments.size === 0
145-
if ((isFirstLoad || this.justResynced) && this.isLive) {
146+
if (this.isLive && (isFirstLoad || this.justResynced)) {
146147
if (this.justResynced) {
147148
this.processedSegments.clear()
148149
this.processedOrder = []
@@ -159,6 +160,8 @@ export default class HLSHandler extends PassThrough {
159160
if (seg.sequence !== -1 && seg.sequence > this.highestSequence) this.highestSequence = seg.sequence
160161
}
161162
this.justResynced = false
163+
} else {
164+
this.justResynced = false
162165
}
163166

164167
const newSegments = parsed.segments.filter(s => {
@@ -209,45 +212,64 @@ export default class HLSHandler extends PassThrough {
209212
}
210213
}
211214

215+
async _fetchWithRetry(segment, attempt = 1) {
216+
try {
217+
if (this.strategy === 'segmented') {
218+
const data = await this.fetcher.fetchSegment(segment, { stream: false })
219+
return { segment, data }
220+
}
221+
const stream = await this.fetcher.fetchSegment(segment, { stream: true })
222+
return { segment, stream }
223+
} catch (err) {
224+
if (this.stop) return null
225+
const isRecoverable = err.message === 'aborted' || err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT'
226+
if (isRecoverable && attempt <= 3) {
227+
const delay = Math.pow(2, attempt) * 500
228+
logger('warn', 'HLSHandler', `Segment fetch failed (attempt ${attempt}/3): ${err.message}. Retrying in ${delay}ms...`)
229+
await new Promise(r => setTimeout(r, delay))
230+
return this._fetchWithRetry(segment, attempt + 1)
231+
}
232+
logger('error', 'HLSHandler', `Segment fetch permanently failed ${segment.sequence}: ${err.message}`)
233+
return null
234+
}
235+
}
236+
212237
async _fetchSegments() {
213238
if (this.isFetching || this.stop) return
214239
this.isFetching = true
215240

216-
let nextSegmentPromise = null
241+
const fetchPool = new Map()
217242

218-
const getSegment = async (seg) => {
219-
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
243+
const fillPool = () => {
244+
while (fetchPool.size < this.MAX_PARALLEL_FETCHES && this.segmentQueue.length > 0) {
245+
const seg = this.segmentQueue.shift()
246+
const key = seg.sequence !== -1 ? seg.sequence : seg.url
247+
fetchPool.set(key, this._fetchWithRetry(seg))
227248
}
228249
}
229250

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-
}
251+
while ((this.segmentQueue.length > 0 || fetchPool.size > 0) && !this.stop) {
252+
fillPool()
235253

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)
254+
if (this.isLive && fetchPool.size === 0 && this.segmentQueue.length === 0 && !this.preRolled) {
255+
await new Promise(r => setTimeout(r, 500))
256+
if (this.segmentQueue.length === 0 && fetchPool.size === 0) break
257+
continue
243258
}
244259

245-
if (!current) continue
260+
if (fetchPool.size === 0) break
246261

247-
if (this.segmentQueue.length > 0 && !this.stop) {
248-
nextSegmentPromise = getSegment(this.segmentQueue.shift())
262+
const [key, promise] = fetchPool.entries().next().value
263+
fetchPool.delete(key)
264+
265+
const current = await promise
266+
if (!current) {
267+
logger('warn', 'HLSHandler', `Skipping failed segment: ${key}`)
268+
continue
249269
}
250270

271+
this.preRolled = true
272+
251273
try {
252274
const { segment, data, stream } = current
253275
if (segment.map && segment.map.uri !== this.lastMapUri) {
@@ -263,20 +285,20 @@ export default class HLSHandler extends PassThrough {
263285
if (!this.write(data)) await new Promise(r => this.once('drain', r))
264286
}
265287
} else if (stream) {
266-
this.activeSegmentStream = stream
288+
this.activeSegmentStreams.set(key, stream)
267289
for await (const chunk of stream) {
268290
if (this.stop) break
269291
if (!this.write(chunk)) await new Promise(r => this.once('drain', r))
270292
}
271-
this.activeSegmentStream = null
293+
this.activeSegmentStreams.delete(key)
272294
}
273295
} catch (err) {
274296
logger('error', 'HLSHandler', `Segment processing error: ${err.message}`)
275297
}
276298
}
277299

278300
this.isFetching = false
279-
if (!this.isLive && this.segmentQueue.length === 0 && !this.stop) {
301+
if (!this.isLive && this.segmentQueue.length === 0 && fetchPool.size === 0 && !this.stop) {
280302
this.emit('finishBuffering')
281303
this.end()
282304
}

src/sources/gaana.js

Lines changed: 10 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
* Credits: https://github.com/southctrl; adapted for NodeLink
33
*/
44

5-
import { PassThrough } from 'node:stream'
65
import { encodeTrack, http1makeRequest, logger } from '../utils.js'
6+
import HLSHandler from '../playback/hls/HLSHandler.js'
77

88
const USER_AGENT =
99
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'
@@ -144,8 +144,11 @@ export default class GaanaSource {
144144

145145
async loadStream(track, url, protocol, additionalData) {
146146
if (protocol === 'hls') {
147-
const stream = new PassThrough()
148-
this.streamHlsPlaylist(stream, url)
147+
const stream = new HLSHandler(url, {
148+
headers: BASE_HEADERS,
149+
strategy: 'segmented',
150+
localAddress: this.nodelink.routePlanner?.getIP()
151+
})
149152
return { stream, type: 'mpegts' }
150153
}
151154

@@ -155,35 +158,11 @@ export default class GaanaSource {
155158
return { stream, type: 'mp4' }
156159
}
157160

158-
const stream = new PassThrough()
159-
this.streamUrl(stream, url)
160-
return { stream, type: 'mp4' }
161-
}
162-
163-
async streamUrl(outputStream, url) {
164-
try {
165-
const { stream, error, statusCode } = await http1makeRequest(url, { method: 'GET', streamOnly: true })
166-
167-
if (error || statusCode !== 200 || !stream) {
168-
throw new Error(error?.message || `Stream status ${statusCode}`)
169-
}
170-
171-
await new Promise((resolve, reject) => {
172-
stream.on('data', (chunk) => {
173-
if (!outputStream.destroyed) outputStream.write(chunk)
174-
})
175-
stream.on('end', resolve)
176-
stream.on('error', reject)
177-
})
178-
} catch (e) {
179-
logger('warn', 'Gaana', `Stream error: ${e.message}`)
180-
if (!outputStream.destroyed) outputStream.emit('error', e)
181-
} finally {
182-
if (!outputStream.destroyed) {
183-
outputStream.emit('finishBuffering')
184-
outputStream.end()
185-
}
161+
const { stream, error, statusCode } = await http1makeRequest(url, { method: 'GET', streamOnly: true, headers: BASE_HEADERS })
162+
if (error || statusCode !== 200 || !stream) {
163+
throw new Error(error?.message || `Stream status ${statusCode}`)
186164
}
165+
return { stream, type: 'mp4' }
187166
}
188167

189168
async streamSegments(outputStream, initUrl, segments) {
@@ -206,79 +185,6 @@ export default class GaanaSource {
206185
}
207186
}
208187

209-
async streamHlsPlaylist(outputStream, playlistUrl) {
210-
try {
211-
const playlist = await this.fetchText(playlistUrl)
212-
if (!playlist) throw new Error('Empty HLS playlist')
213-
214-
const lines = playlist
215-
.split('\n')
216-
.map((l) => l.trim())
217-
.filter(Boolean)
218-
219-
if (lines.some((l) => l.startsWith('#EXTINF'))) {
220-
await this.streamHlsSegments(outputStream, playlistUrl, lines)
221-
return
222-
}
223-
224-
const audioTags = lines.filter(
225-
(l) => l.startsWith('#EXT-X-MEDIA') && l.includes('TYPE=AUDIO') && l.includes('URI="')
226-
)
227-
228-
if (audioTags.length) {
229-
const preferred = audioTags.find((l) => /DEFAULT=YES/.test(l)) || audioTags[audioTags.length - 1]
230-
const uri = preferred.match(/URI="([^"]+)"/)?.[1]
231-
if (uri) {
232-
const audioUrl = new URL(uri, playlistUrl).toString()
233-
const audioPlaylist = await this.fetchText(audioUrl)
234-
const audioLines = audioPlaylist
235-
.split('\n')
236-
.map((l) => l.trim())
237-
.filter(Boolean)
238-
239-
await this.streamHlsSegments(outputStream, audioUrl, audioLines)
240-
return
241-
}
242-
}
243-
244-
const variantUrl = lines.find((l) => !l.startsWith('#'))
245-
if (!variantUrl) throw new Error('No HLS variant found')
246-
247-
const resolved = new URL(variantUrl, playlistUrl).toString()
248-
const variantPlaylist = await this.fetchText(resolved)
249-
const variantLines = variantPlaylist
250-
.split('\n')
251-
.map((l) => l.trim())
252-
.filter(Boolean)
253-
254-
await this.streamHlsSegments(outputStream, resolved, variantLines)
255-
} catch (e) {
256-
if (!outputStream.destroyed) outputStream.emit('error', e)
257-
} finally {
258-
if (!outputStream.destroyed) {
259-
outputStream.emit('finishBuffering')
260-
outputStream.end()
261-
}
262-
}
263-
}
264-
265-
async streamHlsSegments(outputStream, baseUrl, lines) {
266-
const segments = []
267-
268-
for (let i = 0; i < lines.length; i++) {
269-
if (lines[i].startsWith('#EXTINF')) {
270-
const uri = lines[i + 1]
271-
if (uri && !uri.startsWith('#')) segments.push(new URL(uri, baseUrl).toString())
272-
}
273-
}
274-
275-
for (const segmentUrl of segments) {
276-
if (outputStream.destroyed) break
277-
const ok = await this.streamUrlChunk(outputStream, segmentUrl)
278-
if (!ok) break
279-
}
280-
}
281-
282188
async streamUrlChunk(outputStream, url) {
283189
try {
284190
const { stream, statusCode, error } = await http1makeRequest(url, {
@@ -307,20 +213,6 @@ export default class GaanaSource {
307213
}
308214
}
309215

310-
async fetchText(url) {
311-
const { body, statusCode, error } = await http1makeRequest(url, {
312-
method: 'GET',
313-
headers: BASE_HEADERS,
314-
disableBodyCompression: true
315-
})
316-
317-
if (error || statusCode !== 200 || !body) {
318-
throw new Error(error?.message || `Playlist status ${statusCode}`)
319-
}
320-
321-
return typeof body === 'string' ? body : JSON.stringify(body)
322-
}
323-
324216
mapTrack(track) {
325217
const title = track?.title || track?.name
326218
if (!title) return null

0 commit comments

Comments
 (0)