• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nurrony / hlsdownloader / 18321752336

07 Oct 2025 06:01PM UTC coverage: 81.006%. Remained the same
18321752336

push

github

nurrony
fix(package): add fixes from dependency

19 of 23 branches covered (82.61%)

Branch coverage included in aggregate %.

126 of 156 relevant lines covered (80.77%)

19.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.34
/src/Downloader.js
1
import { createWriteStream } from 'fs';
2
import { access, constants, mkdir, unlink } from 'fs/promises';
3
import ky from 'ky';
4
import pLimit from 'p-limit';
5
import { dirname, join } from 'path';
6
import { Readable } from 'stream';
7
import { URL } from 'url';
8
import { InvalidPlayList } from './exceptions';
9
import Utils from './utils';
10

11
/**
12
 * HLS Playlist file extension
13
 * @constant
14
 * @type {string}
15
 * @memberof module:HLSDownloader
16
 */
17
const HLS_PLAYLIST_EXT = '.m3u8';
1✔
18

19
/**
20
 * @class
21
 * @memberof module:HLSDownloader
22
 * @author Nur Rony<pro.nmrony@gmail.com>
23
 * @classdesc Main donwloader class of HLSDownloader Package
24
 */
25
class Downloader {
26
  /** @lends Downloader.prototype */
27

28
  /**
29
   * @static
30
   * @type {object}
31
   * @description Default <a href="https://www.npmjs.com/package/ky" target="_blank">Ky</a> options values set by HLSDownloader
32
   * @default
33
   * <pre>
34
   * {
35
   *   retry: { limit: 0 }
36
   * }
37
   * </pre>
38
   */
39
  static defaultKyOptions = { retry: { limit: 0 } };
1✔
40

41
  /**
42
   * @type {object}
43
   * @default 1
44
   * @description concurrency controller
45
   */
46
  pool = pLimit(1);
51✔
47

48
  /**
49
   * @type {boolean}
50
   * @default false
51
   * @description concurrency controller
52
   */
53
  overwrite = false;
51✔
54

55
  /**
56
   * @type {string[]}
57
   * @default
58
   * <pre>
59
   * [
60
   *  'uri',
61
   *  'url',
62
   *  'json',
63
   *  'form',
64
   *  'body',
65
   *  'method',
66
   *  'setHost',
67
   *  'isStream',
68
   *  'parseJson',
69
   *  'prefixUrl',
70
   *  'cookieJar',
71
   *  'playlistURL',
72
   *  'concurrency',
73
   *  'allowGetBody',
74
   *  'stringifyJson',
75
   *  'methodRewriting'
76
   * ]
77
   * </pre>
78
   */
79
  static unSupportedOptions = [
1✔
80
    'uri',
81
    'url',
82
    'json',
83
    'form',
84
    'body',
85
    'method',
86
    'setHost',
87
    'isStream',
88
    'parseJson',
89
    'prefixUrl',
90
    'cookieJar',
91
    'playlistURL',
92
    'concurrency',
93
    'allowGetBody',
94
    'stringifyJson',
95
    'methodRewriting',
96
  ];
97

98
  /**
99
   * @type {string[]}
100
   * @description items that are downloaded successfully
101
   */
102
  items = [];
51✔
103

104
  /**
105
   * @type {Array<{url: string, name: string, message: string}>}
106
   * @description items that are not downloaded successfully
107
   */
108
  errors = [];
51✔
109

110
  /**
111
   * @type {number=}
112
   * @default 1
113
   * @description Concurrency limit to download items
114
   */
115
  concurrency = 1;
51✔
116

117
  /**
118
   * @type {object=}
119
   * @default <pre>{}</pre>
120
   * @description Extra options to pass into <a href="https://www.npmjs.com/package/ky" target="_blank">Ky</a>
121
   */
122
  kyOptions = {};
51✔
123

124
  /**
125
   * @default ''
126
   * @type {string}
127
   * @description Playlist URL to download
128
   */
129
  playlistURL = '';
51✔
130

131
  /**
132
   * @default ''
133
   * @type {string}
134
   * @description Absolute path to download the TS files with corresponding playlist file
135
   */
136
  destination = '';
51✔
137

138
  /**
139
   * @default null
140
   * @type {Function | null}
141
   * @description Function to track downloaded data
142
   */
143
  onData = null;
51✔
144

145
  /**
146
   * @default null
147
   * @type {Function | null}
148
   * @description Function to track error
149
   */
150
  onError = null;
51✔
151

152
  /**
153
   * @constructor
154
   * @throws TypeError
155
   * @param {object} downloderOptions - Options to build downloader
156
   * @param {string} downloderOptions.playlistURL - Playlist URL to download
157
   * @param {number} [downloderOptions.concurrency = 1] - concurrency limit to download playlist chunk
158
   * @param {string} [downloderOptions.destination = ''] - Absolute path to download
159
   * @param {object | Function | null} [downloderOptions.onData = null] - onData hook
160
   * @param {object | Function | null} [downloderOptions.onError = null] - onError hook
161
   * @param {boolean} [downloderOptions.overwrite = false] - Overwrite files toggler
162
   * @param {object} [downloderOptions.options = {}] - Options to override from <a href="https://www.npmjs.com/package/ky" target="_blank">Ky</a>
163
   * @throws ProtocolNotSupported
164
   */
165
  constructor(
166
    { playlistURL, destination, concurrency = 1, overwrite = false, onData = null, onError = null, ...options } = {
167
      concurrency: 1,
168
      destination: '',
169
      playlistURL: '',
170
      onData: null,
171
      onError: null,
172
      overwrite: false,
173
      options: {},
174
    }
175
  ) {
176
    try {
51✔
177
      this.items = [playlistURL];
51✔
178
      this.playlistURL = playlistURL;
51✔
179
      this.concurrency = concurrency;
51✔
180
      this.overwrite = overwrite ?? false;
51✔
181
      this.destination = destination ?? '';
51✔
182
      this.pool = pLimit(concurrency ?? 1);
51✔
183
      this.kyOptions = this.mergeOptions(options);
51✔
184
      // @ts-ignore
185
      this.onData = onData;
51✔
186
      // @ts-ignore
187
      this.onError = onError;
51✔
188

189
      // method binding
190
      this.fetchItems = this.fetchItems.bind(this);
51✔
191
      this.downloadItem = this.downloadItem.bind(this);
51✔
192
      this.mergeOptions = this.mergeOptions.bind(this);
51✔
193
      this.fetchPlaylist = this.fetchPlaylist.bind(this);
51✔
194
      this.startDownload = this.startDownload.bind(this);
51✔
195
      this.downloadItems = this.downloadItems.bind(this);
51✔
196
      this.shouldOverwrite = this.shouldOverwrite.bind(this);
51✔
197
      this.createDirectory = this.createDirectory.bind(this);
51✔
198
      this.parsePlaylist = this.parsePlaylist.bind(this);
51✔
199
      this.processPlaylistItems = this.processPlaylistItems.bind(this);
51✔
200
      this.formatPlaylistContent = this.formatPlaylistContent.bind(this);
51✔
201

202
      Utils.isValidUrl(playlistURL);
51✔
203

204
      if (this.onData !== null && Utils.isNotFunction(this.onData)) {
50✔
205
        throw TypeError('The `onData` must be a function');
1✔
206
      }
207

208
      if (this.onError !== null && Utils.isNotFunction(this.onError)) {
49✔
209
        throw TypeError('The `onError` must be a function');
1✔
210
      }
211
    } catch (error) {
212
      throw error;
3✔
213
    }
214
  }
215

216
  /**
217
   * @method
218
   * @memberof class:Downloader
219
   * @description Start the downloading process
220
   */
221
  async startDownload() {
222
    const { url, body: playlistContent } = await this.fetchPlaylist(this.playlistURL);
6✔
223
    if (this.errors.length > 0) {
6✔
224
      return {
3✔
225
        errors: this.errors,
226
        message: 'Unsuccessful download',
227
      };
228
    }
229

230
    // @ts-ignore
231
    let urls = this.parsePlaylist(url, playlistContent);
3✔
232
    this.items = [...this.items, ...urls];
3✔
233
    const playlists = urls.filter(url => url.toLowerCase().endsWith(HLS_PLAYLIST_EXT));
18✔
234
    const playlistContentPromiseResults = await Promise.allSettled(playlists.map(this.fetchPlaylist));
3✔
235
    const playlistContents = this.formatPlaylistContent(playlistContentPromiseResults);
3✔
236
    urls = playlistContents.map(content => this.parsePlaylist(content?.url, content?.body)).flat();
3✔
237
    this.items = [...this.items, ...urls];
3✔
238

239
    await this.processPlaylistItems();
3✔
240

241
    if (this.errors.length > 0) {
3✔
242
      return {
3✔
243
        errors: this.errors,
244
        total: this.items.length,
245
        message: 'Download ended with some errors',
246
      };
247
    }
248

249
    return {
×
250
      total: this.items.length,
251
      playlistURL: this.playlistURL,
252
      message: 'Downloaded successfully',
253
    };
254
  }
255

256
  /**
257
   * @returns {object}
258
   * @param {object} options
259
   * @description merge options
260
   */
261
  mergeOptions(options) {
262
    return Object.assign({}, Downloader.defaultKyOptions, Utils.omit(options, ...Downloader.unSupportedOptions));
53✔
263
  }
264

265
  /**
266
   * @method
267
   * @param {string} playlistContent
268
   * @returns string[]  Array of url
269
   * @description Parse playlist content and index the TS chunk to download.
270
   */
271
  // @ts-ignore
272
  parsePlaylist(playlistURL, playlistContent) {
273
    return playlistContent
12✔
274
      .replace(/^#[\s\S].*/gim, '')
275
      .split(/\r?\n/)
276
      .reduce((result, item) => {
277
        if (item !== '') {
156✔
278
          const url = new URL(item, playlistURL).href;
54✔
279
          //@ts-ignore
280
          result.push(url);
54✔
281
        }
282
        return result;
156✔
283
      }, []);
284
  }
285

286
  /**
287
   * @async
288
   * @method
289
   * @returns {Promise<{url: any, body: any}>}
290
   * @description fetch playlist content
291
   */
292
  // @ts-ignore
293
  async fetchPlaylist(url) {
294
    try {
12✔
295
      const body = await ky.get(url, { ...this.kyOptions }).text();
12✔
296
      if (!Utils.isValidPlaylist(body)) {
7✔
297
        const { name, message } = new InvalidPlayList('Invalid playlist');
3✔
298
        this.errors.push({ url, name, message });
3✔
299
        return { url: '', body: '' };
3✔
300
      }
301
      return { url, body };
4✔
302
      // @ts-ignore
303
    } catch ({ name, message }) {
304
      this.errors.push({ url, name, message });
5✔
305
      if (this.onError) {
5✔
306
        this.onError({ name, message, url });
×
307
      }
308
      return { url: '', body: '' };
5✔
309
    }
310
  }
311

312
  /**
313
   * @method
314
   * @description filter playlist contents
315
   * @param {object[]} playlistContentResults  list of fetched playlist content
316
   * @returns {Array<{url: string, body: string}>} list of object containing url and its content
317
   */
318
  formatPlaylistContent(playlistContentResults) {
319
    // @ts-ignore
320
    return playlistContentResults.reduce((contents, { status, value }) => {
4✔
321
      if (status.toLowerCase() === 'fulfilled' && !!value) {
6✔
322
        // @ts-ignore
323
        contents.push(value);
5✔
324
      }
325
      return contents;
6✔
326
    }, []);
327
  }
328

329
  /**
330
   * @async
331
   * @method
332
   * @returns {Promise<any>}
333
   * @description Process playlist items
334
   */
335
  async processPlaylistItems() {
336
    return (this.destination && this.downloadItems()) || this.fetchItems();
3✔
337
  }
338

339
  /**
340
   * @async
341
   * @method
342
   * @description Download each iteam
343
   * @param {string} item - item to download
344
   * @returns {Promise<any>}
345
   */
346
  async downloadItem(item) {
347
    try {
7✔
348
      const response = await ky.get(item, { ...this.kyOptions });
7✔
349
      const filePath = await this.createDirectory(item);
7✔
350
      // @ts-ignore
351
      const readStream = Readable.fromWeb(response.body);
7✔
352
      return new Promise((resolve, reject) => {
×
353
        const writeStream = createWriteStream(filePath);
×
354
        readStream.pipe(writeStream);
×
355

356
        readStream.on('error', error => {
×
357
          readStream.destroy();
×
358
          writeStream.destroy();
×
359
          unlink(filePath);
×
360

361
          if (this.onError) {
×
362
            this.onError({
×
363
              url: item,
364
              name: error.name,
365
              message: error.message,
366
            });
367
          }
368

369
          reject(error);
×
370
        });
371

372
        writeStream.on('finish', () => {
×
373
          writeStream.close();
×
374
          if (this.onData) {
×
375
            this.onData({ url: item, totalItems: this.items.length, path: filePath });
×
376
          }
377
          resolve('success');
×
378
        });
379

380
        writeStream.on('error', error => {
×
381
          writeStream.destroy();
×
382
          readStream.destroy();
×
383
          if (this.onError) {
×
384
            this.onError({
×
385
              url: item,
386
              name: error.name,
387
              message: error.message,
388
            });
389
          }
390
          reject(error);
×
391
        });
392
      });
393
      // @ts-ignore
394
    } catch ({ name, message }) {
395
      this.errors.push({ name, message, url: item });
7✔
396
      if (this.onError) {
7✔
397
        this.onError({ name, message, url: item });
×
398
      }
399
    }
400
  }
401

402
  async downloadItems() {
403
    try {
2✔
404
      if (!(await this.shouldOverwrite(this.playlistURL))) {
2✔
405
        const error = new Error('directory already exists');
1✔
406
        error.name = 'EEXIST';
1✔
407
        throw error;
1✔
408
      }
409
      await this.createDirectory(this.playlistURL);
1✔
410
      // @ts-ignore
411
      const downloaderPromises = this.items.map(url => this.pool(this.downloadItem, url));
7✔
412
      return Promise.allSettled(downloaderPromises);
1✔
413
    } catch (error) {
414
      // @ts-ignore
415
      this.errors.push({ url: this.playlistURL, name: error.name, message: error.message });
1✔
416
      if (this.onError) {
1✔
417
        // @ts-ignore
418
        this.onError({ url: this.playlistURL, name: error.name, message: error.message });
×
419
      }
420
    }
421
  }
422

423
  /**
424
   * @async
425
   * @method
426
   * @description Fetch playlist items
427
   * @returns {Promise<any>}
428
   */
429
  async fetchItems() {
430
    return Promise.allSettled(
1✔
431
      this.items.map(item =>
432
        // @ts-ignore
433
        this.pool(async () => {
7✔
434
          try {
7✔
435
            const item$ = await ky.get(item, { ...this.kyOptions });
7✔
436
            if (this.onData) {
7✔
437
              this.onData({ url: item, totalItems: this.items.length, path: null });
×
438
            }
439
            return item$;
7✔
440
            // @ts-ignore
441
          } catch ({ name, message }) {
442
            this.errors.push({ url: item, name, message });
×
443
            if (this.onError) {
×
444
              this.onError({ url: item, name, message });
×
445
            }
446
          }
447
        })
448
      )
449
    );
450
  }
451

452
  /**
453
   * @description create directory to download
454
   * @returns {Promise<string>} destination path
455
   * @param {string} url url to construct the path from
456
   */
457
  async createDirectory(url) {
458
    // @ts-ignore
459
    const { pathname } = Utils.parseUrl(url);
8✔
460
    const destDirectory = join(this.destination, dirname(pathname));
8✔
461
    await mkdir(destDirectory, { recursive: true });
8✔
462
    return join(this.destination, Utils.stripFirstSlash(pathname));
8✔
463
  }
464

465
  /**
466
   * @method
467
   * @param {string} url - url to build path from
468
   * @description Checks for overwrite flag
469
   * @returns {Promise<boolean>}
470
   */
471
  async shouldOverwrite(url) {
472
    try {
2✔
473
      // @ts-ignore
474
      const { pathname } = Utils.parseUrl(url);
2✔
475
      const destDirectory = join(this.destination, dirname(pathname));
2✔
476
      await access(destDirectory, constants.F_OK);
2✔
477
      return this.overwrite;
1✔
478
    } catch (error) {
479
      // @ts-ignore
480
      if (error.code === 'ENOENT') return true;
1✔
481
      throw error;
×
482
    }
483
  }
484
}
485

486
/**
487
 * @author Nur Rony<pro.nmrony@gmail.com>
488
 * @classdesc Downloads or fetch HLS Playlist and its items
489
 */
490
export default Downloader;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc