hlsdownloader 6.0.1 โ†’ 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,7 +48,7 @@ Downloads HLS Playlist file and TS chunks. You can use it for content pre-fetchi
48
48
  - **Overwrite Protection**: Safeguards your local data by preventing accidental overwriting of existing files unless explicitly enabled.
49
49
  - **Support for Custom HTTP Headers**: Allows injection of custom headers for handling authentication, user-agents, or session tokens.
50
50
  - **Concurrent Downloads**: Maximizes bandwidth by fetching multiple HLS segments simultaneously through parallel HTTP connections.
51
- - **Proxy and NoProxy Support:** Proxy support and No Proxy support (undici integration).
51
+ - **Proxy and NoProxy Support:** Proxy support and No Proxy support ([undici](https://github.com/nodejs/undici) integration).
52
52
  - **Professional Docs**: Integrated JSDoc-to-HTML pipeline using TypeDoc and the Fresh theme.
53
53
 
54
54
  ---
@@ -59,6 +59,8 @@ Downloads HLS Playlist file and TS chunks. You can use it for content pre-fetchi
59
59
  npm install hlsdownloader
60
60
  ```
61
61
 
62
+ > **Note: This library requires [undici](https://github.com/nodejs/undici) as a peer dependency for proxy support.**
63
+
62
64
  ## Examples
63
65
 
64
66
  #### Example 1: Basic Download with Event Monitoring
@@ -165,6 +167,40 @@ downloader.on('error', err => {
165
167
  const summary = await downloader.startDownload();
166
168
  ```
167
169
 
170
+ #### Example 4: Simple progress bar
171
+
172
+ This example demostrate a simple download progressbar. You can bring your own progress bar implementation
173
+
174
+ ```ts
175
+ import { HLSDownloader } from 'hlsdownloader';
176
+
177
+ const downloader = new HLSDownloader({
178
+ playlistURL: 'https://stream.example.com/playlist/master.m3u8',
179
+ concurrency: 5,
180
+ retry: { limit: 3, delay: 1000 },
181
+ headers: { 'User-Agent': 'MyHeader' },
182
+ timeout: 15000,
183
+ });
184
+
185
+ downloader.on('start', ({ total }) => {
186
+ console.log(`Starting download of ${total} segments...`);
187
+ });
188
+
189
+ downloader.on('progress', ({ processed, total, url }) => {
190
+ const percentage = Math.round((processed / total) * 100);
191
+ const progressBar = '='.repeat(Math.floor(percentage / 5)).padEnd(20, ' ');
192
+ process.stdout.clearLine(0);
193
+ process.stdout.cursorTo(0);
194
+ process.stdout.write(`[${progressBar}] ${percentage}% | Processing: ${processed}/${total}`);
195
+ });
196
+
197
+ downloader.on('end', () => {
198
+ process.stdout.write('\nDownload Complete!\n');
199
+ });
200
+
201
+ await downloader.startDownload();
202
+ ```
203
+
168
204
  ## API Documentation
169
205
 
170
206
  The library is organized under the `HLSDownloader` module. For full interactive documentation, visit our [Documentation](https://nurrony.github.io/hlsdownloader) site.
@@ -210,11 +246,12 @@ The main service orchestrator for fetching HLS content.
210
246
 
211
247
  ### SegmentDownloadedData (Interface) - emits on `progress` events
212
248
 
213
- | Property | Type | Description |
214
- | -------- | -------- | -------------------------------------------------------------------------- |
215
- | url | `string` | Original segment URL as referenced in the HLS playlist (`.m3u8`). |
216
- | path | `string` | Local file system path where the segment was saved. Empty if not provided. |
217
- | total | `number` | Total number of segments downloaded so far, including this one. |
249
+ | Property | Type | Description |
250
+ | --------- | -------- | -------------------------------------------------------------------------- |
251
+ | url | `string` | Original segment URL as referenced in the HLS playlist (`.m3u8`). |
252
+ | path | `string` | Local file system path where the segment was saved. Empty if not provided. |
253
+ | processed | `number` | Total number of segments downloaded so far. |
254
+ | total | `number` | Total number of segments downloaded to download, including this one. |
218
255
 
219
256
  ---
220
257
 
package/dist/index.d.ts CHANGED
@@ -30,11 +30,13 @@ export interface SegmentDownloadedData {
30
30
  url: string;
31
31
  path?: string;
32
32
  total: number;
33
+ processed: number;
33
34
  }
34
- export interface SegmentDownloadErrorData {
35
+ export interface SegmentDownloadErrorData extends DownloadError {
35
36
  url: string;
36
37
  name: string;
37
38
  message: string;
39
+ processed: number;
38
40
  }
39
41
  export interface DownloaderOptions extends HttpClientOptions {
40
42
  playlistURL: string;
@@ -57,6 +59,8 @@ declare class Downloader extends EventEmitter {
57
59
  private fileService;
58
60
  private errors;
59
61
  private concurrency;
62
+ private processedCount;
63
+ private isDownloading;
60
64
  constructor(options: DownloaderOptions);
61
65
  startDownload(): Promise<DownloadSummary>;
62
66
  emit(event: string | symbol, ...args: any[]): boolean;
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import{EventEmitter as k}from"node:events";import{cpus as A}from"node:os";import C from"p-limit";import{URL as v}from"node:url";var h=class l extends Error{constructor(r){super(r),this.name=this.constructor.name,Object.setPrototypeOf(this,l.prototype),Error.captureStackTrace&&Error.captureStackTrace(this,this.constructor)}},f=h;var n=class{static isValidUrl(r,t=["http:","https:","ftp:","sftp:"]){let{protocol:e}=new v(r);if(e&&!t.includes(e))throw new f(`${e} is not supported. Supported protocols are ${t.join(", ")}`);return!0}static stripFirstSlash(r){return r.startsWith("/")?r.substring(1):r}static isValidPlaylist(r){return/^#EXTM3U/im.test(r)}static parseUrl(r){return new v(r)}static omit(r,...t){let e=new Set(t.flat());return Object.fromEntries(Object.entries(r).filter(([s])=>!e.has(s)))}static isNotFunction(r){return typeof r!="function"}static async sleep(r){return new Promise(t=>setTimeout(t,r))}};import{constants as x,createWriteStream as O}from"node:fs";import*as m from"node:fs/promises";import S from"node:path";import{Readable as L}from"node:stream";import{pipeline as T}from"node:stream/promises";var d=class{destination;overwrite;constructor(r,t=!1){this.destination=r,this.overwrite=t}async getTargetPath(r){let{pathname:t}=n.parseUrl(r);return S.join(this.destination,n.stripFirstSlash(t))}async prepareDirectory(r){let t=await this.getTargetPath(r),e=S.dirname(t);return await m.mkdir(e,{recursive:!0}),t}async canWrite(r){try{let t=await this.getTargetPath(r);return await m.access(t,x.F_OK),this.overwrite}catch(t){if(t.code==="ENOENT")return!0;throw t}}async saveStream(r,t){let e=L.fromWeb(r),s=O(t);try{await T(e,s)}catch(i){throw await m.unlink(t).catch(()=>{}),i}}},D=d;import{ProxyAgent as U}from"undici";var u=class l extends Error{constructor(r){super(r),this.name=this.constructor.name,Object.setPrototypeOf(this,l.prototype),Error.captureStackTrace&&Error.captureStackTrace(this,this.constructor)}},P=u;var y=class{options;primaryOrigin=null;sensitiveHeaders=["authorization","cookie","x-auth-token"];timeout;retryOptions;dispatcher;noProxy=[];constructor(r={}){if(this.options={method:"GET",headers:{"User-Agent":"HLSDownloader"}},r.headers){let s={};for(let[i,o]of Object.entries(r.headers))s[i.toLowerCase()]=o;this.options.headers={"User-Agent":"HLSDownloader",...this.options.headers,...s}}this.timeout=r.timeout??5e3,this.retryOptions=r.retry??{limit:1,delay:500};let t=process.env.NO_PROXY||process.env.no_proxy||"";this.noProxy=r.noProxy??t.split(",").map(s=>s.trim()).filter(Boolean);let e=r.proxy||process.env.HTTPS_PROXY||process.env.HTTP_PROXY;e&&(this.dispatcher=new U({uri:e}))}setPrimaryOrigin(r){try{this.primaryOrigin=new URL(r).origin}catch{this.primaryOrigin=null}}getRequestHeaders(r){let t={...this.options.headers};try{let e=new URL(r).origin;this.primaryOrigin&&e!==this.primaryOrigin&&this.sensitiveHeaders.forEach(s=>{delete t[s]})}catch{}return t}shouldBypassProxy(r){if(!this.dispatcher)return!0;let t=new URL(r).hostname;return this.noProxy.some(e=>t===e||e.startsWith(".")&&t.endsWith(e))}async requestWithRetry(r,t=0){let e=new AbortController,s=setTimeout(()=>e.abort(),this.timeout);try{let i=await fetch(r,{...this.options,headers:this.getRequestHeaders(r),signal:e.signal,dispatcher:this.shouldBypassProxy(r)?void 0:this.dispatcher});return clearTimeout(s),!i.ok&&(i.status>=500||i.status===429)&&t<this.retryOptions.limit?this.handleRetry(r,t):i}catch(i){if(clearTimeout(s),(i.name==="AbortError"||i.name==="TypeError"||i.status&&i.status>=500)&&t<this.retryOptions.limit)return this.handleRetry(r,t);throw i}}async handleRetry(r,t){let e=this.retryOptions.delay*Math.pow(2,t);return await n.sleep(e),this.requestWithRetry(r,t+1)}async fetchText(r){let e=await(await this.requestWithRetry(r)).text();if(!n.isValidPlaylist(e))throw new P("Invalid playlist");return e}async getStream(r){let t=await this.requestWithRetry(r);if(!t.body)throw new Error("Response body is null");return t.body}},b=y;import{URL as H}from"node:url";var g=class l{static HLS_PLAYLIST_EXT=".m3u8";parse(r,t){return t.replace(/^#[\s\S].*/gim,"").split(/\r?\n/).filter(e=>e.trim()!=="").map(e=>new H(e,r).href)}isPlaylist(r){return r.toLowerCase().endsWith(l.HLS_PLAYLIST_EXT)}},p=new g;var w=class extends k{constructor(t){super();this.options=t;let{destination:e="",playlistURL:s="",overwrite:i=!1,concurrency:o=Math.max(1,A().length-1),headers:a={},...c}=t||{};this.playlistURL=s,this.pool=C(o),this.concurrency=o,this.http=new b({headers:a,...c}),this.http.setPrimaryOrigin(this.playlistURL),this.fileService=new D(e,i),this.items.add(this.playlistURL)}items=new Set;playlistURL;pool;http;fileService;errors=[];concurrency=1;async startDownload(){try{n.isValidUrl(this.playlistURL);let t=await this.http.fetchText(this.playlistURL),e=p.parse(this.playlistURL,t);e.forEach(a=>this.items.add(a));let s=e.filter(a=>p.isPlaylist(a));(await Promise.allSettled(s.map(a=>this.http.fetchText(a)))).forEach((a,c)=>{a.status==="fulfilled"&&p.parse(s[c],a.value).forEach(E=>this.items.add(E))}),this.emit("start",{total:this.items.size,destination:this.options.destination}),await this.processQueue();let o=this.generateSummary();return this.emit("end",o),o}catch(t){return this.handleError(this.playlistURL,t),this.generateSummary()}}emit(t,...e){return super.emit(t,...e)}async processQueue(){let t=this.items.size,e=Array.from(this.items);if(this.fileService.destination){if(!await this.fileService.canWrite(this.playlistURL))throw new Error("Directory already exists and overwrite is disabled");let i=e.map(o=>this.pool(()=>this.downloadFile(o)));return Promise.allSettled(i)}let s=e.map(i=>this.pool(async()=>{try{let o=await this.http.getStream(i);return this.emit("progress",{url:i,total:t}),o}catch(o){this.handleError(i,o)}}));return Promise.allSettled(s)}async downloadFile(t){try{let e=await this.http.getStream(t),s=await this.fileService.prepareDirectory(t);await this.fileService.saveStream(e,s),this.emit("progress",{url:t,path:s,total:this.items.size})}catch(e){this.handleError(t,e)}}handleError(t,e){let s={url:t,name:e.name,message:e.message};this.errors.push(s),this.emit("error",s)}generateSummary(){return{errors:this.errors,total:this.items.size,message:this.errors.length>0?"Download ended with errors":"Downloaded successfully"}}},R=w;var gt=R;export{R as HLSDownloader,gt as default};
1
+ import{EventEmitter as H}from"node:events";import{cpus as k}from"node:os";import A from"p-limit";import{URL as v}from"node:url";var d=class l extends Error{constructor(r){super(r),this.name=this.constructor.name,Object.setPrototypeOf(this,l.prototype),Error.captureStackTrace&&Error.captureStackTrace(this,this.constructor)}},f=d;var a=class{static isValidUrl(r,t=["http:","https:","ftp:","sftp:"]){let{protocol:e}=new v(r);if(e&&!t.includes(e))throw new f(`${e} is not supported. Supported protocols are ${t.join(", ")}`);return!0}static stripFirstSlash(r){return r.startsWith("/")?r.substring(1):r}static isValidPlaylist(r){return/^#EXTM3U/im.test(r)}static parseUrl(r){return new v(r)}static omit(r,...t){let e=new Set(t.flat());return Object.fromEntries(Object.entries(r).filter(([i])=>!e.has(i)))}static isNotFunction(r){return typeof r!="function"}static async sleep(r){return new Promise(t=>setTimeout(t,r))}};import{constants as x,createWriteStream as O}from"node:fs";import*as p from"node:fs/promises";import S from"node:path";import{Readable as L}from"node:stream";import{pipeline as T}from"node:stream/promises";var h=class{destination;overwrite;constructor(r,t=!1){this.destination=r,this.overwrite=t}async getTargetPath(r){let{pathname:t}=a.parseUrl(r);return S.join(this.destination,a.stripFirstSlash(t))}async prepareDirectory(r){let t=await this.getTargetPath(r),e=S.dirname(t);return await p.mkdir(e,{recursive:!0}),t}async canWrite(r){try{let t=await this.getTargetPath(r);return await p.access(t,x.F_OK),this.overwrite}catch(t){if(t.code==="ENOENT")return!0;throw t}}async saveStream(r,t){let e=L.fromWeb(r),i=O(t);try{await T(e,i)}catch(s){throw await p.unlink(t).catch(()=>{}),s}}},D=h;import{ProxyAgent as U}from"undici";var u=class l extends Error{constructor(r){super(r),this.name=this.constructor.name,Object.setPrototypeOf(this,l.prototype),Error.captureStackTrace&&Error.captureStackTrace(this,this.constructor)}},P=u;var y=class{options;primaryOrigin=null;sensitiveHeaders=["authorization","cookie","x-auth-token"];timeout;retryOptions;dispatcher;noProxy=[];constructor(r={}){if(this.options={method:"GET",headers:{"User-Agent":"HLSDownloader"}},r.headers){let i={};for(let[s,o]of Object.entries(r.headers))i[s.toLowerCase()]=o;this.options.headers={"User-Agent":"HLSDownloader",...this.options.headers,...i}}this.timeout=r.timeout??5e3,this.retryOptions=r.retry??{limit:1,delay:500};let t=process.env.NO_PROXY||process.env.no_proxy||"";this.noProxy=r.noProxy??t.split(",").map(i=>i.trim()).filter(Boolean);let e=r.proxy||process.env.HTTPS_PROXY||process.env.HTTP_PROXY;e&&(this.dispatcher=new U({uri:e}))}setPrimaryOrigin(r){try{this.primaryOrigin=new URL(r).origin}catch{this.primaryOrigin=null}}getRequestHeaders(r){let t={...this.options.headers};try{let e=new URL(r).origin;this.primaryOrigin&&e!==this.primaryOrigin&&this.sensitiveHeaders.forEach(i=>{delete t[i]})}catch{}return t}shouldBypassProxy(r){if(!this.dispatcher)return!0;let t=new URL(r).hostname;return this.noProxy.some(e=>t===e||e.startsWith(".")&&t.endsWith(e))}async requestWithRetry(r,t=0){let e=new AbortController,i=setTimeout(()=>e.abort(),this.timeout);try{let s=await fetch(r,{...this.options,headers:this.getRequestHeaders(r),signal:e.signal,dispatcher:this.shouldBypassProxy(r)?void 0:this.dispatcher});return clearTimeout(i),!s.ok&&(s.status>=500||s.status===429)&&t<this.retryOptions.limit?this.handleRetry(r,t):s}catch(s){if(clearTimeout(i),(s.name==="AbortError"||s.name==="TypeError"||s.status&&s.status>=500)&&t<this.retryOptions.limit)return this.handleRetry(r,t);throw s}}async handleRetry(r,t){let e=this.retryOptions.delay*Math.pow(2,t);return await a.sleep(e),this.requestWithRetry(r,t+1)}async fetchText(r){let e=await(await this.requestWithRetry(r)).text();if(!a.isValidPlaylist(e))throw new P("Invalid playlist");return e}async getStream(r){let t=await this.requestWithRetry(r);if(!t.body)throw new Error("Response body is null");return t.body}},b=y;import{URL as C}from"node:url";var g=class l{static HLS_PLAYLIST_EXT=".m3u8";parse(r,t){return t.replace(/^#[\s\S].*/gim,"").split(/\r?\n/).filter(e=>e.trim()!=="").map(e=>new C(e,r).href)}isPlaylist(r){return r.toLowerCase().endsWith(l.HLS_PLAYLIST_EXT)}},c=new g;var w=class extends H{constructor(t){super();this.options=t;let{destination:e="",playlistURL:i="",overwrite:s=!1,concurrency:o=Math.max(1,k().length-1),headers:n={},...m}=t||{};this.playlistURL=i,this.pool=A(o),this.concurrency=o,this.http=new b({headers:n,...m}),this.http.setPrimaryOrigin(this.playlistURL),this.fileService=new D(e,s),this.items.add(this.playlistURL)}items=new Set;playlistURL;pool;http;fileService;errors=[];concurrency=1;processedCount=0;isDownloading=!1;async startDownload(){if(this.isDownloading)throw new Error("Download already in progress on this instance.");try{this.isDownloading=!0,this.processedCount=0,this.errors=[],a.isValidUrl(this.playlistURL);let t=await this.http.fetchText(this.playlistURL),e=c.parse(this.playlistURL,t);e.forEach(n=>this.items.add(n));let i=e.filter(n=>c.isPlaylist(n));(await Promise.allSettled(i.map(n=>this.http.fetchText(n)))).forEach((n,m)=>{n.status==="fulfilled"&&c.parse(i[m],n.value).forEach(E=>this.items.add(E))}),this.processedCount=0,this.emit("start",{total:this.items.size,destination:this.options.destination}),await this.processQueue();let o=this.generateSummary();return this.emit("end",o),o}catch(t){return this.handleError(this.playlistURL,t),this.generateSummary()}finally{this.isDownloading=!1}}emit(t,...e){return super.emit(t,...e)}async processQueue(){let t=this.items.size,e=Array.from(this.items);if(this.fileService.destination){if(!await this.fileService.canWrite(this.playlistURL))throw new Error("Directory already exists and overwrite is disabled");let s=e.map(o=>this.pool(()=>this.downloadFile(o)));return Promise.allSettled(s)}let i=e.map(s=>this.pool(async()=>{try{let o=await this.http.getStream(s);return this.processedCount++,this.emit("progress",{url:s,total:t,processed:this.processedCount}),o}catch(o){this.handleError(s,o)}}));return Promise.allSettled(i)}async downloadFile(t){try{let e=this.items.size,i=await this.http.getStream(t),s=await this.fileService.prepareDirectory(t);await this.fileService.saveStream(i,s),this.processedCount++,this.emit("progress",{url:t,path:s,total:e,processed:this.processedCount})}catch(e){this.handleError(t,e)}}handleError(t,e){this.processedCount++;let i={url:t,name:e.name,message:e.message,processed:this.processedCount};this.errors.push(i),this.emit("error",i)}generateSummary(){return{errors:this.errors,total:this.items.size,message:this.errors.length>0?"Download ended with errors":"Downloaded successfully"}}},R=w;var gt=R;export{R as HLSDownloader,gt as default};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hlsdownloader",
3
- "version": "6.0.1",
3
+ "version": "6.1.1",
4
4
  "description": "Downloads HLS Playlist file and TS chunks",
5
5
  "snyk": true,
6
6
  "main": "./dist/index.js",
@@ -25,13 +25,12 @@
25
25
  "prepublishOnly": "echo '๐Ÿš€ Release: Final automated validation gate before NPM publishing.' && npm run build",
26
26
  "build:types": "echo '๐Ÿท๏ธ Generating Types' && dts-bundle-generator -o dist/index.d.ts src/index.ts --no-check --silent",
27
27
  "docs": "rimraf -fr ./docs && echo '๐Ÿงน All documentation has been cleaned.' && echo '๐Ÿ“ Generating documentation...' && jsdoc -c jsdoc.json",
28
- "build:bundle": "esbuild src/index.ts --bundle --packages=external --external:undici --platform=node --format=esm --target=node20 --minify --tree-shaking=true --outfile=dist/index.js",
28
+ "build:bundle": "esbuild src/index.ts --bundle --packages=external --platform=node --format=esm --target=node20 --minify --tree-shaking=true --outfile=dist/index.js",
29
29
  "build": "echo '๐Ÿ“ฆ Bundles ESM source and generates minified distribution files.' && npm run typecheck && rimraf -fr ./dist && npm run build:types && npm run build:bundle",
30
30
  "test:coverage": "rimraf -fr ./coverage && echo '๐Ÿงน All test coverage reports has been cleaned.' && c8 npm test && echo '๐Ÿ“Š Coverage: Validates 100% code coverage across all source files successfully.'"
31
31
  },
32
32
  "dependencies": {
33
- "p-limit": "^7.3.0",
34
- "undici": "^7.22.0"
33
+ "p-limit": "^7.3.0"
35
34
  },
36
35
  "devDependencies": {
37
36
  "@babel/core": "^7.29.0",
@@ -54,7 +53,11 @@
54
53
  "semantic-release": "^25.0.3",
55
54
  "tsx": "^4.21.0",
56
55
  "typescript": "^5.9.3",
57
- "typescript-eslint": "^8.56.1"
56
+ "typescript-eslint": "^8.56.1",
57
+ "undici": "^7.22.0"
58
+ },
59
+ "peerDependencies": {
60
+ "undici": "^7.22.0"
58
61
  },
59
62
  "engines": {
60
63
  "node": ">=20.0.0",