getraw 0.1.0

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.
Files changed (105) hide show
  1. package/.gitattributes +4 -0
  2. package/CLAUDE.md +57 -0
  3. package/README.md +166 -0
  4. package/RESEARCH.md +109 -0
  5. package/STATUS.md +23 -0
  6. package/bun.lock +50 -0
  7. package/bunfig.toml +3 -0
  8. package/docs/plugin-guide.md +166 -0
  9. package/docs/supported-sites.md +41 -0
  10. package/package.json +30 -0
  11. package/src/cli/index.ts +52 -0
  12. package/src/cli/options.ts +97 -0
  13. package/src/core/format-sorter.ts +208 -0
  14. package/src/core/logger.ts +101 -0
  15. package/src/core/orchestrator.ts +140 -0
  16. package/src/core/output-template.ts +58 -0
  17. package/src/core/types.ts +237 -0
  18. package/src/downloaders/base.ts +25 -0
  19. package/src/downloaders/dash.ts +287 -0
  20. package/src/downloaders/fragment.ts +226 -0
  21. package/src/downloaders/hls.ts +170 -0
  22. package/src/downloaders/http.ts +260 -0
  23. package/src/extractors/archive-org.ts +126 -0
  24. package/src/extractors/bandcamp.ts +130 -0
  25. package/src/extractors/base.ts +29 -0
  26. package/src/extractors/bilibili/bangumi.ts +205 -0
  27. package/src/extractors/bilibili/index.ts +233 -0
  28. package/src/extractors/bilibili/wbi.ts +60 -0
  29. package/src/extractors/coub.ts +137 -0
  30. package/src/extractors/dailymotion.ts +99 -0
  31. package/src/extractors/dropbox.ts +52 -0
  32. package/src/extractors/generic.ts +118 -0
  33. package/src/extractors/google-drive.ts +106 -0
  34. package/src/extractors/imgur.ts +156 -0
  35. package/src/extractors/instagram/index.ts +263 -0
  36. package/src/extractors/instagram/reels.ts +166 -0
  37. package/src/extractors/kick/clips.ts +91 -0
  38. package/src/extractors/kick/index.ts +118 -0
  39. package/src/extractors/kick/live.ts +89 -0
  40. package/src/extractors/niconico/index.ts +209 -0
  41. package/src/extractors/odysee.ts +126 -0
  42. package/src/extractors/peertube.ts +143 -0
  43. package/src/extractors/reddit/gallery.ts +124 -0
  44. package/src/extractors/reddit/index.ts +203 -0
  45. package/src/extractors/rumble.ts +127 -0
  46. package/src/extractors/soundcloud/index.ts +161 -0
  47. package/src/extractors/soundcloud/playlist.ts +129 -0
  48. package/src/extractors/spotify.ts +97 -0
  49. package/src/extractors/streamable.ts +121 -0
  50. package/src/extractors/ted.ts +151 -0
  51. package/src/extractors/tiktok/index.ts +207 -0
  52. package/src/extractors/tiktok/user.ts +176 -0
  53. package/src/extractors/twitch/clips.ts +125 -0
  54. package/src/extractors/twitch/index.ts +136 -0
  55. package/src/extractors/twitch/live.ts +132 -0
  56. package/src/extractors/twitter/index.ts +140 -0
  57. package/src/extractors/twitter/spaces.ts +200 -0
  58. package/src/extractors/vimeo/index.ts +187 -0
  59. package/src/extractors/youtube/captions.ts +111 -0
  60. package/src/extractors/youtube/index.ts +252 -0
  61. package/src/extractors/youtube/innertube.ts +364 -0
  62. package/src/extractors/youtube/nsig.ts +105 -0
  63. package/src/extractors/youtube/playlist.ts +227 -0
  64. package/src/extractors/youtube/signature.ts +163 -0
  65. package/src/networking/client.ts +311 -0
  66. package/src/networking/cookies.ts +138 -0
  67. package/src/networking/proxy.ts +132 -0
  68. package/src/networking/tls.ts +67 -0
  69. package/src/networking/user-agents.ts +88 -0
  70. package/src/postprocessors/base.ts +44 -0
  71. package/src/postprocessors/extract-audio.ts +98 -0
  72. package/src/postprocessors/ffmpeg.ts +146 -0
  73. package/src/postprocessors/merge.ts +102 -0
  74. package/src/postprocessors/metadata.ts +73 -0
  75. package/src/postprocessors/sponsorblock.ts +162 -0
  76. package/src/postprocessors/subtitles.ts +285 -0
  77. package/src/postprocessors/thumbnails.ts +194 -0
  78. package/src/utils/sanitize.ts +36 -0
  79. package/src/utils/traverse.ts +68 -0
  80. package/tests/core/format-sorter.test.ts +96 -0
  81. package/tests/core/output-template.test.ts +56 -0
  82. package/tests/core/types.test.ts +79 -0
  83. package/tests/unit/downloaders/dash.test.ts +57 -0
  84. package/tests/unit/downloaders/hls.test.ts +120 -0
  85. package/tests/unit/downloaders/http.test.ts +114 -0
  86. package/tests/unit/extractors/bilibili.test.ts +83 -0
  87. package/tests/unit/extractors/instagram.test.ts +273 -0
  88. package/tests/unit/extractors/kick.test.ts +85 -0
  89. package/tests/unit/extractors/misc.test.ts +942 -0
  90. package/tests/unit/extractors/niconico.test.ts +61 -0
  91. package/tests/unit/extractors/reddit.test.ts +222 -0
  92. package/tests/unit/extractors/soundcloud.test.ts +299 -0
  93. package/tests/unit/extractors/tiktok.test.ts +260 -0
  94. package/tests/unit/extractors/twitch.test.ts +250 -0
  95. package/tests/unit/extractors/twitter.test.ts +181 -0
  96. package/tests/unit/extractors/vimeo.test.ts +253 -0
  97. package/tests/unit/extractors/youtube.test.ts +259 -0
  98. package/tests/unit/networking/client.test.ts +272 -0
  99. package/tests/unit/networking/cookies.test.ts +256 -0
  100. package/tests/unit/networking/proxy.test.ts +137 -0
  101. package/tests/unit/postprocessors/extract-audio.test.ts +63 -0
  102. package/tests/unit/postprocessors/merge.test.ts +61 -0
  103. package/tests/unit/postprocessors/subtitles.test.ts +89 -0
  104. package/tools/dashboard.ts +112 -0
  105. package/tsconfig.json +17 -0
@@ -0,0 +1,237 @@
1
+ export interface Thumbnail {
2
+ url: string;
3
+ width?: number;
4
+ height?: number;
5
+ id?: string;
6
+ preference?: number;
7
+ }
8
+
9
+ export interface Subtitle {
10
+ url: string;
11
+ ext: string;
12
+ name?: string;
13
+ data?: string;
14
+ }
15
+
16
+ export interface Chapter {
17
+ title: string;
18
+ start_time: number;
19
+ end_time: number;
20
+ }
21
+
22
+ export interface Format {
23
+ format_id: string;
24
+ url: string;
25
+ ext: string;
26
+ protocol?: string;
27
+ width?: number;
28
+ height?: number;
29
+ resolution?: string;
30
+ fps?: number;
31
+ vcodec?: string;
32
+ acodec?: string;
33
+ vbr?: number;
34
+ abr?: number;
35
+ tbr?: number;
36
+ filesize?: number;
37
+ filesize_approx?: number;
38
+ format_note?: string;
39
+ quality?: number;
40
+ language?: string;
41
+ audio_channels?: number;
42
+ dynamic_range?: string;
43
+ has_drm?: boolean;
44
+ http_headers?: Record<string, string>;
45
+ source_preference?: number;
46
+ container?: string;
47
+ }
48
+
49
+ export interface InfoDict {
50
+ id: string;
51
+ title: string;
52
+ url?: string;
53
+ ext?: string;
54
+ formats?: Format[];
55
+ thumbnails?: Thumbnail[];
56
+ subtitles?: Record<string, Subtitle[]>;
57
+ automatic_captions?: Record<string, Subtitle[]>;
58
+ chapters?: Chapter[];
59
+ description?: string;
60
+ uploader?: string;
61
+ uploader_id?: string;
62
+ uploader_url?: string;
63
+ channel?: string;
64
+ channel_id?: string;
65
+ channel_url?: string;
66
+ duration?: number;
67
+ view_count?: number;
68
+ like_count?: number;
69
+ comment_count?: number;
70
+ upload_date?: string;
71
+ timestamp?: number;
72
+ age_limit?: number;
73
+ webpage_url?: string;
74
+ categories?: string[];
75
+ tags?: string[];
76
+ live_status?: "is_live" | "was_live" | "is_upcoming" | "not_live";
77
+ release_timestamp?: number;
78
+ playlist?: string;
79
+ playlist_index?: number;
80
+ playlist_count?: number;
81
+ entries?: InfoDict[];
82
+ requested_formats?: Format[];
83
+ filename?: string;
84
+ _type?: "video" | "playlist" | "url" | "url_transparent";
85
+ extractor?: string;
86
+ extractor_key?: string;
87
+ http_headers?: Record<string, string>;
88
+ }
89
+
90
+ export interface DownloadProgress {
91
+ downloaded_bytes: number;
92
+ total_bytes: number | null;
93
+ speed: number | null;
94
+ eta: number | null;
95
+ percent: number | null;
96
+ status: "downloading" | "finished" | "error";
97
+ filename: string;
98
+ }
99
+
100
+ export interface Options {
101
+ format: string;
102
+ output: string;
103
+ extractAudio: boolean;
104
+ audioFormat: string;
105
+ audioQuality: string;
106
+ writeSubs: boolean;
107
+ subLangs: string;
108
+ listFormats: boolean;
109
+ dumpJson: boolean;
110
+ quiet: boolean;
111
+ verbose: boolean;
112
+ noProgress: boolean;
113
+ retries: number;
114
+ rateLimit: number | null;
115
+ proxy: string | null;
116
+ cookies: string | null;
117
+ userAgent: string;
118
+ referer: string | null;
119
+ embedThumbnail: boolean;
120
+ embedSubs: boolean;
121
+ mergeOutputFormat: string | null;
122
+ paths: { home: string; temp: string };
123
+ ffmpegLocation: string | null;
124
+ version: boolean;
125
+ help: boolean;
126
+ urls: string[];
127
+ }
128
+
129
+ export abstract class BaseExtractor {
130
+ abstract readonly _VALID_URL: RegExp;
131
+ abstract readonly _NAME: string;
132
+
133
+ canHandle(url: string): boolean {
134
+ return this._VALID_URL.test(url);
135
+ }
136
+
137
+ async extract(url: string): Promise<InfoDict> {
138
+ if (!this.canHandle(url)) {
139
+ throw new ExtractorError(`URL not supported by ${this._NAME}: ${url}`);
140
+ }
141
+ try {
142
+ const info = await this._real_extract(url);
143
+ info.extractor = this._NAME;
144
+ info.extractor_key = this.constructor.name;
145
+ return info;
146
+ } catch (err) {
147
+ if (err instanceof ExtractorError) throw err;
148
+ throw new ExtractorError(
149
+ `${this._NAME}: extraction failed: ${err instanceof Error ? err.message : String(err)}`,
150
+ { cause: err },
151
+ );
152
+ }
153
+ }
154
+
155
+ protected abstract _real_extract(url: string): Promise<InfoDict>;
156
+ }
157
+
158
+ export abstract class Downloader {
159
+ abstract readonly protocol: string;
160
+
161
+ abstract download(
162
+ url: string,
163
+ filepath: string,
164
+ options: DownloadOptions,
165
+ ): Promise<void>;
166
+
167
+ abstract canHandle(protocol: string): boolean;
168
+ }
169
+
170
+ export interface DownloadOptions {
171
+ headers?: Record<string, string>;
172
+ rateLimit?: number | null;
173
+ retries?: number;
174
+ onProgress?: (progress: DownloadProgress) => void;
175
+ }
176
+
177
+ export abstract class PostProcessor {
178
+ abstract readonly _NAME: string;
179
+
180
+ abstract run(info: InfoDict, filepath: string): Promise<PostProcessResult>;
181
+ }
182
+
183
+ export interface PostProcessResult {
184
+ filepath: string;
185
+ info: InfoDict;
186
+ files_to_delete: string[];
187
+ }
188
+
189
+ export class ExtractorError extends Error {
190
+ constructor(message: string, options?: ErrorOptions) {
191
+ super(message, options);
192
+ this.name = "ExtractorError";
193
+ }
194
+ }
195
+
196
+ export class DownloadError extends Error {
197
+ constructor(message: string, options?: ErrorOptions) {
198
+ super(message, options);
199
+ this.name = "DownloadError";
200
+ }
201
+ }
202
+
203
+ export class PostProcessError extends Error {
204
+ constructor(message: string, options?: ErrorOptions) {
205
+ super(message, options);
206
+ this.name = "PostProcessError";
207
+ }
208
+ }
209
+
210
+ export const DEFAULT_OPTIONS: Options = {
211
+ format: "bv*+ba/b",
212
+ output: "%(title)s [%(id)s].%(ext)s",
213
+ extractAudio: false,
214
+ audioFormat: "mp3",
215
+ audioQuality: "5",
216
+ writeSubs: false,
217
+ subLangs: "en",
218
+ listFormats: false,
219
+ dumpJson: false,
220
+ quiet: false,
221
+ verbose: false,
222
+ noProgress: false,
223
+ retries: 3,
224
+ rateLimit: null,
225
+ proxy: null,
226
+ cookies: null,
227
+ userAgent: "dlpx/0.0.0",
228
+ referer: null,
229
+ embedThumbnail: false,
230
+ embedSubs: false,
231
+ mergeOutputFormat: null,
232
+ paths: { home: ".", temp: "" },
233
+ ffmpegLocation: null,
234
+ version: false,
235
+ help: false,
236
+ urls: [],
237
+ };
@@ -0,0 +1,25 @@
1
+ import { Downloader } from "../core/types";
2
+ import { HttpDownloader } from "./http";
3
+ import { HlsDownloader } from "./hls";
4
+ import { DashDownloader } from "./dash";
5
+
6
+ export { Downloader };
7
+
8
+ const downloaders: Downloader[] = [
9
+ new HttpDownloader(),
10
+ new HlsDownloader(),
11
+ new DashDownloader(),
12
+ ];
13
+
14
+ export function registerDownloader(downloader: Downloader): void {
15
+ downloaders.push(downloader);
16
+ }
17
+
18
+ export function getDownloader(protocol: string): Downloader | null {
19
+ for (const dl of downloaders) {
20
+ if (dl.canHandle(protocol)) {
21
+ return dl;
22
+ }
23
+ }
24
+ return null;
25
+ }
@@ -0,0 +1,287 @@
1
+ import { Downloader, DownloadError } from "../core/types";
2
+ import type { DownloadOptions } from "../core/types";
3
+ import { FragmentDownloader } from "./fragment";
4
+ import type { Segment } from "./fragment";
5
+ import { logger } from "../core/logger";
6
+
7
+ interface DashRepresentation {
8
+ id: string;
9
+ bandwidth?: number;
10
+ width?: number;
11
+ height?: number;
12
+ codecs?: string;
13
+ mimeType?: string;
14
+ segments?: DashSegmentInfo;
15
+ initialization?: string;
16
+ segmentList?: DashSegmentList;
17
+ segmentTemplate?: DashSegmentTemplate;
18
+ }
19
+
20
+ interface DashAdaptationSet {
21
+ id?: string;
22
+ contentType?: string;
23
+ mimeType?: string;
24
+ representations: DashRepresentation[];
25
+ segments?: DashSegmentInfo;
26
+ segmentTemplate?: DashSegmentTemplate;
27
+ }
28
+
29
+ interface DashPeriod {
30
+ id?: string;
31
+ adaptationSets: DashAdaptationSet[];
32
+ }
33
+
34
+ interface DashMpd {
35
+ periods: DashPeriod[];
36
+ }
37
+
38
+ interface DashSegmentInfo {
39
+ list: Array<{ uri: string; timeline: Array<{ start: number; end: number; uri: string }> }>;
40
+ timeline: Array<{ start: number; end: number; uri: string }>;
41
+ initialization?: { sourceURL?: string };
42
+ }
43
+
44
+ interface DashSegmentList {
45
+ initialization?: { sourceURL?: string };
46
+ segmentURLs: Array<{ media?: string }>;
47
+ }
48
+
49
+ interface DashSegmentTemplate {
50
+ initialization?: string;
51
+ media?: string;
52
+ timescale?: number;
53
+ duration?: number;
54
+ startNumber?: number;
55
+ segmentTimeline?: Array<{ t?: number; d: number; r?: number }>;
56
+ }
57
+
58
+ export class DashDownloader extends Downloader {
59
+ readonly protocol = "dash";
60
+
61
+ canHandle(protocol: string): boolean {
62
+ return protocol === "dash" || protocol === "mpd";
63
+ }
64
+
65
+ async download(
66
+ url: string,
67
+ filepath: string,
68
+ options: DownloadOptions,
69
+ ): Promise<void> {
70
+ const retries = options.retries ?? 3;
71
+ let lastError: Error | null = null;
72
+
73
+ for (let attempt = 1; attempt <= retries; attempt++) {
74
+ try {
75
+ await this.downloadDash(url, filepath, options);
76
+ return;
77
+ } catch (err) {
78
+ lastError = err instanceof Error ? err : new Error(String(err));
79
+ if (attempt < retries) {
80
+ const delay = 1000 * Math.pow(2, attempt - 1);
81
+ logger.warn(`DASH download failed (attempt ${attempt}/${retries}): ${lastError.message}`);
82
+ await new Promise((r) => setTimeout(r, delay));
83
+ }
84
+ }
85
+ }
86
+
87
+ throw new DownloadError(`DASH download failed after ${retries} attempts: ${lastError?.message}`);
88
+ }
89
+
90
+ private async fetchMpd(url: string, headers: Record<string, string>): Promise<string> {
91
+ const resp = await fetch(url, { headers });
92
+ if (!resp.ok) {
93
+ throw new DownloadError(`Failed to fetch MPD: HTTP ${resp.status}`);
94
+ }
95
+ return resp.text();
96
+ }
97
+
98
+ private async downloadDash(url: string, filepath: string, options: DownloadOptions): Promise<void> {
99
+ const { parse } = await import("mpd-parser");
100
+ const headers = { ...options.headers };
101
+
102
+ const mpdText = await this.fetchMpd(url, headers);
103
+ const parsed = parse(mpdText, { manifestUri: url }) as DashMpd;
104
+
105
+ if (!parsed.periods || parsed.periods.length === 0) {
106
+ throw new DownloadError("MPD has no periods");
107
+ }
108
+
109
+ const allSegments: Segment[] = [];
110
+ let globalIdx = 0;
111
+
112
+ for (const period of parsed.periods) {
113
+ const periodSegments = this.extractPeriodSegments(period, url, globalIdx);
114
+ globalIdx += periodSegments.length;
115
+ allSegments.push(...periodSegments);
116
+ }
117
+
118
+ if (allSegments.length === 0) {
119
+ throw new DownloadError("No segments found in MPD");
120
+ }
121
+
122
+ const tempDir = `/tmp/dlpx-dash-${Date.now()}`;
123
+ const fragmenter = new FragmentDownloader();
124
+ await fragmenter.downloadSegments(allSegments, filepath, {
125
+ ...options,
126
+ concurrency: 8,
127
+ tempDir,
128
+ });
129
+ }
130
+
131
+ private extractPeriodSegments(period: DashPeriod, baseUrl: string, startIdx: number): Segment[] {
132
+ const { videoSet, audioSet } = this.selectAdaptationSets(period.adaptationSets);
133
+
134
+ const segments: Segment[] = [];
135
+ let idx = startIdx;
136
+
137
+ if (videoSet) {
138
+ const best = this.selectBestRepresentation(videoSet.representations);
139
+ if (best) {
140
+ const segs = this.buildSegments(best, videoSet, baseUrl, idx);
141
+ idx += segs.length;
142
+ segments.push(...segs);
143
+ logger.debug(`DASH: selected video representation ${best.id} (${best.bandwidth ?? "?"} bps)`);
144
+ }
145
+ }
146
+
147
+ if (audioSet) {
148
+ const best = this.selectBestRepresentation(audioSet.representations);
149
+ if (best) {
150
+ const segs = this.buildSegments(best, audioSet, baseUrl, idx);
151
+ segments.push(...segs);
152
+ logger.debug(`DASH: selected audio representation ${best.id} (${best.bandwidth ?? "?"} bps)`);
153
+ }
154
+ }
155
+
156
+ return segments;
157
+ }
158
+
159
+ private selectAdaptationSets(sets: DashAdaptationSet[]): {
160
+ videoSet: DashAdaptationSet | null;
161
+ audioSet: DashAdaptationSet | null;
162
+ } {
163
+ let videoSet: DashAdaptationSet | null = null;
164
+ let audioSet: DashAdaptationSet | null = null;
165
+
166
+ for (const set of sets) {
167
+ const mime = set.mimeType ?? set.contentType ?? "";
168
+ if (mime.startsWith("video") && !videoSet) {
169
+ videoSet = set;
170
+ } else if (mime.startsWith("audio") && !audioSet) {
171
+ audioSet = set;
172
+ }
173
+ }
174
+
175
+ if (!videoSet && sets.length > 0) {
176
+ videoSet = sets[0];
177
+ }
178
+
179
+ return { videoSet, audioSet };
180
+ }
181
+
182
+ private selectBestRepresentation(reps: DashRepresentation[]): DashRepresentation | null {
183
+ if (!reps || reps.length === 0) return null;
184
+ return reps.reduce((a, b) => (b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a);
185
+ }
186
+
187
+ private buildSegments(
188
+ rep: DashRepresentation,
189
+ set: DashAdaptationSet,
190
+ baseUrl: string,
191
+ startIdx: number,
192
+ ): Segment[] {
193
+ const segments: Segment[] = [];
194
+ let idx = startIdx;
195
+
196
+ const template = rep.segmentTemplate ?? set.segmentTemplate;
197
+ if (template) {
198
+ return this.buildFromTemplate(template, rep, baseUrl, startIdx);
199
+ }
200
+
201
+ const segList = rep.segmentList;
202
+ if (segList) {
203
+ if (segList.initialization?.sourceURL) {
204
+ segments.push({
205
+ url: resolveUrl(baseUrl, segList.initialization.sourceURL),
206
+ index: idx++,
207
+ isInit: true,
208
+ });
209
+ }
210
+ for (const s of segList.segmentURLs) {
211
+ if (s.media) {
212
+ segments.push({ url: resolveUrl(baseUrl, s.media), index: idx++ });
213
+ }
214
+ }
215
+ return segments;
216
+ }
217
+
218
+ const info = rep.segments ?? set.segments;
219
+ if (info) {
220
+ if (info.initialization?.sourceURL) {
221
+ segments.push({
222
+ url: resolveUrl(baseUrl, info.initialization.sourceURL),
223
+ index: idx++,
224
+ isInit: true,
225
+ });
226
+ }
227
+ for (const entry of info.timeline ?? []) {
228
+ if (entry.uri) {
229
+ segments.push({ url: resolveUrl(baseUrl, entry.uri), index: idx++ });
230
+ }
231
+ }
232
+ }
233
+
234
+ return segments;
235
+ }
236
+
237
+ private buildFromTemplate(
238
+ template: DashSegmentTemplate,
239
+ rep: DashRepresentation,
240
+ baseUrl: string,
241
+ startIdx: number,
242
+ ): Segment[] {
243
+ const segments: Segment[] = [];
244
+ let idx = startIdx;
245
+
246
+ if (template.initialization) {
247
+ const initUrl = interpolateTemplate(template.initialization, rep.id, 0, 0);
248
+ segments.push({ url: resolveUrl(baseUrl, initUrl), index: idx++, isInit: true });
249
+ }
250
+
251
+ const mediaTemplate = template.media;
252
+ if (!mediaTemplate) return segments;
253
+
254
+ if (template.segmentTimeline && template.segmentTimeline.length > 0) {
255
+ const timescale = template.timescale ?? 1;
256
+ let t = 0;
257
+ let segNum = template.startNumber ?? 1;
258
+
259
+ for (const entry of template.segmentTimeline) {
260
+ if (entry.t !== undefined) t = entry.t;
261
+ const repeat = (entry.r ?? 0) + 1;
262
+ for (let r = 0; r < repeat; r++) {
263
+ const segUrl = interpolateTemplate(mediaTemplate, rep.id, segNum, t);
264
+ segments.push({ url: resolveUrl(baseUrl, segUrl), index: idx++ });
265
+ t += entry.d;
266
+ segNum++;
267
+ }
268
+ }
269
+ } else if (template.duration && template.timescale) {
270
+ logger.warn("DASH: duration-based SegmentTemplate without SegmentTimeline — segment count unknown, skipping");
271
+ }
272
+
273
+ return segments;
274
+ }
275
+ }
276
+
277
+ function interpolateTemplate(template: string, repId: string, number: number, time: number): string {
278
+ return template
279
+ .replace("$RepresentationID$", repId)
280
+ .replace(/\$Number(%0\d+d)?\$/g, (_, fmt) => fmt ? number.toString().padStart(parseInt(fmt.slice(2)), "0") : String(number))
281
+ .replace(/\$Time(%0\d+d)?\$/g, (_, fmt) => fmt ? time.toString().padStart(parseInt(fmt.slice(2)), "0") : String(time));
282
+ }
283
+
284
+ function resolveUrl(base: string, relative: string): string {
285
+ if (/^https?:\/\//i.test(relative)) return relative;
286
+ return new URL(relative, base).toString();
287
+ }