ytdlp-react-native 1.0.0 → 1.1.0-beta.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 (51) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.md +422 -267
  3. package/android/src/main/AndroidManifest.xml +20 -1
  4. package/android/src/main/java/expo/modules/ytdlp/ExpoYtDlpModule.kt +8 -0
  5. package/android/src/main/java/expo/modules/ytdlp/YtDlpDownloadManager.kt +177 -16
  6. package/android/src/main/java/expo/modules/ytdlp/YtDlpEngine.kt +35 -4
  7. package/android/src/main/java/expo/modules/ytdlp/YtDlpForegroundService.kt +241 -0
  8. package/android/src/main/java/expo/modules/ytdlp/YtDlpTask.kt +44 -2
  9. package/build/ExpoYtDlpModule.d.ts +2 -0
  10. package/build/ExpoYtDlpModule.d.ts.map +1 -1
  11. package/build/ExpoYtDlpModule.js.map +1 -1
  12. package/build/YtDlp.d.ts +10 -0
  13. package/build/YtDlp.d.ts.map +1 -1
  14. package/build/YtDlp.js +22 -77
  15. package/build/YtDlp.js.map +1 -1
  16. package/build/constants.d.ts +1 -4
  17. package/build/constants.d.ts.map +1 -1
  18. package/build/constants.js +2 -1
  19. package/build/constants.js.map +1 -1
  20. package/build/downloadTask.d.ts +2 -0
  21. package/build/downloadTask.d.ts.map +1 -1
  22. package/build/downloadTask.js +7 -0
  23. package/build/downloadTask.js.map +1 -1
  24. package/build/errors.js +3 -3
  25. package/build/errors.js.map +1 -1
  26. package/build/index.d.ts +3 -1
  27. package/build/index.d.ts.map +1 -1
  28. package/build/index.js +3 -1
  29. package/build/index.js.map +1 -1
  30. package/build/mappers.js +3 -2
  31. package/build/mappers.js.map +1 -1
  32. package/build/serializers.d.ts +5 -0
  33. package/build/serializers.d.ts.map +1 -0
  34. package/build/serializers.js +96 -0
  35. package/build/serializers.js.map +1 -0
  36. package/build/types.d.ts +39 -1
  37. package/build/types.d.ts.map +1 -1
  38. package/build/types.js.map +1 -1
  39. package/package.json +7 -5
  40. package/src/ExpoYtDlpModule.ts +2 -0
  41. package/src/YtDlp.ts +21 -57
  42. package/src/__tests__/errors.test.ts +117 -0
  43. package/src/__tests__/mappers.test.ts +167 -0
  44. package/src/__tests__/serializers.test.ts +72 -0
  45. package/src/constants.ts +3 -1
  46. package/src/downloadTask.ts +9 -0
  47. package/src/errors.ts +3 -3
  48. package/src/index.ts +3 -1
  49. package/src/mappers.ts +3 -2
  50. package/src/serializers.ts +78 -0
  51. package/src/types.ts +47 -1
@@ -0,0 +1,167 @@
1
+ import { mapDownloadProgress, mapFormat, mapVideoInfo } from '../mappers';
2
+
3
+ describe('mapVideoInfo', () => {
4
+ it('maps known fields and ignores unknown ones', () => {
5
+ const info = mapVideoInfo({
6
+ id: 'abc',
7
+ title: 'Hello',
8
+ duration: 12.5,
9
+ duration_string: '00:12',
10
+ view_count: 100,
11
+ is_live: true,
12
+ live_status: 'is_live',
13
+ extractor: 'youtube',
14
+ thumbnail: 'https://example.com/t.jpg',
15
+ formats: [],
16
+ });
17
+
18
+ expect(info.id).toBe('abc');
19
+ expect(info.title).toBe('Hello');
20
+ expect(info.duration).toBe(12.5);
21
+ expect(info.durationString).toBe('00:12');
22
+ expect(info.viewCount).toBe(100);
23
+ expect(info.isLive).toBe(true);
24
+ expect(info.wasLive).toBe(false);
25
+ expect(info.extractor).toBe('youtube');
26
+ expect(info.thumbnail).toBe('https://example.com/t.jpg');
27
+ expect(info.formats).toEqual([]);
28
+ });
29
+
30
+ it('treats missing fields as undefined', () => {
31
+ const info = mapVideoInfo({});
32
+ expect(info.title).toBeUndefined();
33
+ expect(info.duration).toBeUndefined();
34
+ expect(info.likeCount).toBeUndefined();
35
+ expect(info.formats).toEqual([]);
36
+ });
37
+
38
+ it('maps non-live statuses as not live', () => {
39
+ expect(mapVideoInfo({ live_status: 'was_live' }).wasLive).toBe(true);
40
+ expect(mapVideoInfo({ live_status: 'not_live' }).isLive).toBe(false);
41
+ });
42
+
43
+ it('normalizes thumbnails and falls back to the single thumbnail', () => {
44
+ const info = mapVideoInfo({
45
+ thumbnails: [
46
+ { url: 'https://example.com/a.jpg', width: 120, height: 90 },
47
+ { url: 'https://example.com/b.jpg' },
48
+ ],
49
+ });
50
+ expect(info.thumbnails).toHaveLength(2);
51
+ expect(info.thumbnails[0]).toEqual({
52
+ url: 'https://example.com/a.jpg',
53
+ width: 120,
54
+ height: 90,
55
+ resolution: undefined,
56
+ id: undefined,
57
+ });
58
+ });
59
+
60
+ it('skips thumbnails without a url and creates one from the single thumbnail', () => {
61
+ const info = mapVideoInfo({
62
+ thumbnails: [{ width: 10 }],
63
+ thumbnail: 'https://example.com/fallback.jpg',
64
+ });
65
+ expect(info.thumbnails).toHaveLength(1);
66
+ expect(info.thumbnails[0].url).toBe('https://example.com/fallback.jpg');
67
+ });
68
+
69
+ it('converts numeric strings to numbers', () => {
70
+ const info = mapVideoInfo({ duration: '42', view_count: '7' });
71
+ expect(info.duration).toBe(42);
72
+ expect(info.viewCount).toBe(7);
73
+ });
74
+ });
75
+
76
+ describe('mapFormat', () => {
77
+ it('maps a combined stream with video and audio', () => {
78
+ const format = mapFormat({
79
+ format_id: '18',
80
+ url: 'https://example.com/v.mp4',
81
+ ext: 'mp4',
82
+ width: 640,
83
+ height: 360,
84
+ vcodec: 'avc1',
85
+ acodec: 'mp4a',
86
+ filesize: 1000,
87
+ });
88
+
89
+ expect(format.id).toBe('18');
90
+ expect(format.url).toBe('https://example.com/v.mp4');
91
+ expect(format.width).toBe(640);
92
+ expect(format.hasVideo).toBe(true);
93
+ expect(format.hasAudio).toBe(true);
94
+ expect(format.audioOnly).toBe(false);
95
+ expect(format.videoOnly).toBe(false);
96
+ });
97
+
98
+ it('detects audio-only and video-only streams', () => {
99
+ const audio = mapFormat({ format_id: '140', vcodec: 'none', acodec: 'mp4a' });
100
+ expect(audio.hasAudio).toBe(true);
101
+ expect(audio.hasVideo).toBe(false);
102
+ expect(audio.audioOnly).toBe(true);
103
+
104
+ const video = mapFormat({ format_id: '137', vcodec: 'avc1', acodec: 'none' });
105
+ expect(video.hasVideo).toBe(true);
106
+ expect(video.hasAudio).toBe(false);
107
+ expect(video.videoOnly).toBe(true);
108
+ });
109
+
110
+ it('falls back to a blank string id rather than leaking garbage', () => {
111
+ expect(mapFormat({}).id).toBe('');
112
+ expect(mapFormat({}).hasVideo).toBe(false);
113
+ });
114
+
115
+ it('maps format id from either key', () => {
116
+ expect(mapFormat({ id: 'x' }).id).toBe('x');
117
+ expect(mapFormat({ format_id: 'y' }).id).toBe('y');
118
+ });
119
+
120
+ it('treats non-finite numbers as undefined', () => {
121
+ const format = mapFormat({ width: NaN, fps: 'NaN', abr: Infinity });
122
+ expect(format.width).toBeUndefined();
123
+ expect(format.fps).toBeUndefined();
124
+ expect(format.abr).toBeUndefined();
125
+ });
126
+ });
127
+
128
+ describe('mapDownloadProgress', () => {
129
+ it('maps a full progress payload', () => {
130
+ const progress = mapDownloadProgress(
131
+ {
132
+ status: 'downloading',
133
+ phase: 'downloading',
134
+ percent: 50,
135
+ downloadedBytes: 100,
136
+ totalBytes: 200,
137
+ speedBytesPerSecond: 10,
138
+ etaSeconds: 5,
139
+ filename: 'video.mp4',
140
+ },
141
+ 'task-1'
142
+ );
143
+
144
+ expect(progress).not.toBeNull();
145
+ expect(progress?.taskId).toBe('task-1');
146
+ expect(progress?.status).toBe('downloading');
147
+ expect(progress?.percent).toBe(50);
148
+ expect(progress?.filename).toBe('video.mp4');
149
+ });
150
+
151
+ it('normalizes phase values', () => {
152
+ expect(mapDownloadProgress({ phase: 'extracting' }, 't')?.phase).toBe('extracting');
153
+ expect(mapDownloadProgress({ phase: 'processing' }, 't')?.phase).toBe('processing');
154
+ expect(mapDownloadProgress({ phase: 'unknown' }, 't')?.phase).toBe('downloading');
155
+ });
156
+
157
+ it('returns null for non-object payloads', () => {
158
+ expect(mapDownloadProgress(null, 't')).toBeNull();
159
+ expect(mapDownloadProgress('downloading', 't')).toBeNull();
160
+ });
161
+
162
+ it('never returns NaN - missing numbers become undefined', () => {
163
+ const progress = mapDownloadProgress({ percent: NaN, etaSeconds: 'bad' }, 't');
164
+ expect(progress?.percent).toBeUndefined();
165
+ expect(progress?.etaSeconds).toBeUndefined();
166
+ });
167
+ });
@@ -0,0 +1,72 @@
1
+ import { YtDlpError } from '../errors';
2
+ import { serializeDownloadOptions, serializeExtractOptions } from '../serializers';
3
+
4
+ describe('serializeDownloadOptions', () => {
5
+ it('sends url and format through', () => {
6
+ const out = serializeDownloadOptions({
7
+ url: 'https://x.test/v',
8
+ format: 'bestvideo+bestaudio',
9
+ });
10
+ expect(out.url).toBe('https://x.test/v');
11
+ expect(out.format).toBe('bestvideo+bestaudio');
12
+ });
13
+
14
+ it('passes ffmpeg.location through', () => {
15
+ const out = serializeDownloadOptions({
16
+ url: 'https://x.test/v',
17
+ format: 'bestvideo+bestaudio',
18
+ ffmpeg: { location: '/data/user/0/com.app/files/ffmpeg' },
19
+ });
20
+ expect(out.ffmpeg).toEqual({ location: '/data/user/0/com.app/files/ffmpeg' });
21
+ });
22
+
23
+ it('trims ffmpeg.location whitespace', () => {
24
+ const out = serializeDownloadOptions({
25
+ url: 'https://x.test/v',
26
+ ffmpeg: { location: ' /data/ffmpeg ' },
27
+ });
28
+ expect(out.ffmpeg).toEqual({ location: '/data/ffmpeg' });
29
+ });
30
+
31
+ it('throws PROCESSING_FAILED for a blank ffmpeg.location', () => {
32
+ expect(() =>
33
+ serializeDownloadOptions({ url: 'https://x.test/v', ffmpeg: { location: ' ' } })
34
+ ).toThrow(YtDlpError);
35
+ try {
36
+ serializeDownloadOptions({ url: 'https://x.test/v', ffmpeg: { location: ' ' } });
37
+ } catch (error) {
38
+ expect((error as YtDlpError).code).toBe('PROCESSING_FAILED');
39
+ }
40
+ });
41
+
42
+ it('omits ffmpeg when not provided', () => {
43
+ const out = serializeDownloadOptions({ url: 'https://x.test/v' });
44
+ expect(out.ffmpeg).toBeUndefined();
45
+ });
46
+
47
+ it('passes merge through', () => {
48
+ expect(serializeDownloadOptions({ url: 'u', merge: true }).merge).toBe(true);
49
+ expect(serializeDownloadOptions({ url: 'u' }).merge).toBeUndefined();
50
+ });
51
+ });
52
+
53
+ describe('serializeExtractOptions', () => {
54
+ it('returns an empty object when no options are given', () => {
55
+ expect(serializeExtractOptions(undefined)).toEqual({});
56
+ expect(serializeExtractOptions({})).toEqual({});
57
+ });
58
+
59
+ it('merges a custom User-Agent into headers', () => {
60
+ const out = serializeExtractOptions({ userAgent: 'UA/1.0' });
61
+ expect(out.headers).toEqual({ 'User-Agent': 'UA/1.0' });
62
+ });
63
+
64
+ it('passes headers and cookies through', () => {
65
+ const out = serializeExtractOptions({
66
+ headers: { Referer: 'https://x.test' },
67
+ cookies: { source: 'file', path: '/data/cookies.txt' },
68
+ });
69
+ expect(out.headers).toEqual({ Referer: 'https://x.test' });
70
+ expect(out.cookies).toEqual({ path: '/data/cookies.txt' });
71
+ });
72
+ });
package/src/constants.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Package-level constants.
3
3
  */
4
- export const LIBRARY_VERSION = '0.1.0';
4
+ import pkg from '../package.json';
5
+
6
+ export const LIBRARY_VERSION = pkg.version;
5
7
 
6
8
  export const SUPPORTED_PLATFORM = 'android';
7
9
 
@@ -76,6 +76,14 @@ export class YtDlpDownloadTask implements DownloadTask {
76
76
  await this.native.cancelDownload(this.id);
77
77
  }
78
78
 
79
+ async pause(): Promise<boolean> {
80
+ return this.native.pauseDownload(this.id);
81
+ }
82
+
83
+ async resume(): Promise<boolean> {
84
+ return this.native.resumeDownload(this.id);
85
+ }
86
+
79
87
  async getStatus(): Promise<DownloadStatus> {
80
88
  if (this.finalized) return this.currentStatus;
81
89
  const info = await this.native.getDownloadStatus(this.id);
@@ -144,6 +152,7 @@ function mapStatusInfo(
144
152
  function phaseOf(status: DownloadStatus): DownloadProgress['phase'] {
145
153
  switch (status) {
146
154
  case 'downloading':
155
+ case 'paused':
147
156
  return 'downloading';
148
157
  case 'completed':
149
158
  case 'processing':
package/src/errors.ts CHANGED
@@ -109,6 +109,9 @@ export function normalizeExtractionError(
109
109
  ) {
110
110
  return new YtDlpError('NETWORK_ERROR', base.message, { cause: base.cause });
111
111
  }
112
+ if (message.includes('age') || message.includes('mature') || message.includes('under 18')) {
113
+ return new YtDlpError('AGE_RESTRICTED', base.message, { cause: base.cause });
114
+ }
112
115
  if (
113
116
  message.includes('sign in to confirm') ||
114
117
  message.includes('log in') ||
@@ -119,9 +122,6 @@ export function normalizeExtractionError(
119
122
  if (message.includes('private') || message.includes('members-only')) {
120
123
  return new YtDlpError('PRIVATE_CONTENT', base.message, { cause: base.cause });
121
124
  }
122
- if (message.includes('age') || message.includes('mature') || message.includes('under 18')) {
123
- return new YtDlpError('AGE_RESTRICTED', base.message, { cause: base.cause });
124
- }
125
125
  if (
126
126
  message.includes('geographic') ||
127
127
  message.includes('geo-') ||
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { cancel, download, extractInfo, getFormats, getVersion } from './YtDlp';
1
+ import { cancel, download, extractInfo, getFormats, getVersion, pause, resume } from './YtDlp';
2
2
  import { YtDlpError } from './errors';
3
3
 
4
4
  export type * from './types';
@@ -10,6 +10,8 @@ export const YtDlp = {
10
10
  getFormats,
11
11
  download,
12
12
  cancel,
13
+ pause,
14
+ resume,
13
15
  };
14
16
 
15
17
  export { YtDlpError };
package/src/mappers.ts CHANGED
@@ -88,8 +88,8 @@ export function mapFormat(raw: unknown): Format {
88
88
  * `DownloadProgress`, replacing any non-finite numbers with `undefined`.
89
89
  */
90
90
  export function mapDownloadProgress(raw: unknown, taskId: string): DownloadProgress | null {
91
- const progress = asRecord(raw);
92
- if (!progress) return null;
91
+ if (typeof raw !== 'object' || raw === null) return null;
92
+ const progress = raw as RawRecord;
93
93
 
94
94
  const percent = numberOf(progress.percent);
95
95
  const phaseRaw = stringOf(progress.phase);
@@ -114,6 +114,7 @@ function statusOf(value: unknown): DownloadProgress['status'] {
114
114
  case 'extracting':
115
115
  case 'downloading':
116
116
  case 'processing':
117
+ case 'paused':
117
118
  case 'completed':
118
119
  case 'cancelled':
119
120
  case 'failed':
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Pure serializers that translate public options into the plain map sent to
3
+ * the native layer. Kept free of native imports so they can be unit-tested
4
+ * (AGENTS.md §49, §41).
5
+ */
6
+ import { YtDlpError } from './errors';
7
+ import type { CookieOptions, DownloadOptions, ExtractOptions } from './types';
8
+
9
+ export function serializeExtractOptions(
10
+ options: ExtractOptions | undefined
11
+ ): Record<string, unknown> {
12
+ if (!options) return {};
13
+ const out: Record<string, unknown> = {};
14
+ if (options.cookies) out.cookies = serializeCookies(options.cookies);
15
+ if (options.proxy) out.proxy = options.proxy;
16
+ if (options.userAgent || options.headers) {
17
+ const headers: Record<string, string> = { ...(options.headers ?? {}) };
18
+ if (options.userAgent) headers['User-Agent'] = options.userAgent;
19
+ out.headers = headers;
20
+ }
21
+ return out;
22
+ }
23
+
24
+ export function serializeDownloadOptions(options: DownloadOptions): Record<string, unknown> {
25
+ const out: Record<string, unknown> = { url: options.url };
26
+ if (options.format) out.format = options.format;
27
+ if (options.output) {
28
+ const output: Record<string, unknown> = {};
29
+ if (options.output.directory) output.directory = options.output.directory;
30
+ if (options.output.filename) output.filename = options.output.filename;
31
+ if (Object.keys(output).length > 0) out.output = output;
32
+ }
33
+ if (options.merge != null) out.merge = options.merge;
34
+ if (options.ffmpeg?.location) {
35
+ const location = options.ffmpeg.location.trim();
36
+ if (!location) {
37
+ throw new YtDlpError('PROCESSING_FAILED', 'ffmpeg.location must not be empty.');
38
+ }
39
+ out.ffmpeg = { location };
40
+ }
41
+ if (options.cookies) out.cookies = serializeCookies(options.cookies);
42
+ if (options.userAgent || options.headers) {
43
+ const headers: Record<string, string> = { ...(options.headers ?? {}) };
44
+ if (options.userAgent) headers['User-Agent'] = options.userAgent;
45
+ out.headers = headers;
46
+ }
47
+ if (options.referer) out.referer = options.referer;
48
+ if (options.proxy) out.proxy = options.proxy;
49
+ if (options.playlist) {
50
+ const playlist: Record<string, unknown> = {};
51
+ if (options.playlist.enabled != null) playlist.enabled = options.playlist.enabled;
52
+ if (options.playlist.start != null) playlist.start = options.playlist.start;
53
+ if (options.playlist.end != null) playlist.end = options.playlist.end;
54
+ if (Object.keys(playlist).length > 0) out.playlist = playlist;
55
+ }
56
+ if (options.network) {
57
+ const network: Record<string, unknown> = {};
58
+ if (options.network.timeout != null) network.timeout = options.network.timeout;
59
+ if (options.network.retries != null) network.retries = options.network.retries;
60
+ if (Object.keys(network).length > 0) out.network = network;
61
+ }
62
+ if (options.subtitles) {
63
+ const subtitles: Record<string, unknown> = {};
64
+ if (options.subtitles.enabled != null) subtitles.enabled = options.subtitles.enabled;
65
+ if (options.subtitles.languages?.length) subtitles.languages = options.subtitles.languages;
66
+ if (options.subtitles.autoGenerated != null)
67
+ subtitles.autoGenerated = options.subtitles.autoGenerated;
68
+ if (Object.keys(subtitles).length > 0) out.subtitles = subtitles;
69
+ }
70
+ return out;
71
+ }
72
+
73
+ export function serializeCookies(cookies: CookieOptions): Record<string, unknown> {
74
+ if (cookies.source === 'file' && cookies.path) {
75
+ return { path: cookies.path };
76
+ }
77
+ throw new YtDlpError('INVALID_URL', 'Cookies must reference a cookies file path.');
78
+ }
package/src/types.ts CHANGED
@@ -7,7 +7,14 @@
7
7
  import type { YtDlpError } from './errors';
8
8
 
9
9
  export type DownloadStatus =
10
- 'queued' | 'extracting' | 'downloading' | 'processing' | 'completed' | 'cancelled' | 'failed';
10
+ | 'queued'
11
+ | 'extracting'
12
+ | 'downloading'
13
+ | 'processing'
14
+ | 'paused'
15
+ | 'completed'
16
+ | 'cancelled'
17
+ | 'failed';
11
18
 
12
19
  export type DownloadPhase = 'extracting' | 'downloading' | 'processing';
13
20
 
@@ -106,6 +113,21 @@ export interface NetworkOptions {
106
113
  retries?: number;
107
114
  }
108
115
 
116
+ /**
117
+ * Optional FFmpeg integration (see issue #2). The package does **not** bundle
118
+ * FFmpeg — yt-dlp needs a real executable to merge separate video+audio
119
+ * streams (`bestvideo+bestaudio`), extract/re-encode audio, or embed metadata
120
+ * and thumbnails. Point `location` at an ffmpeg binary already on the device.
121
+ */
122
+ export interface FfmpegOptions {
123
+ /**
124
+ * Absolute filesystem path to an `ffmpeg` executable, or to a directory
125
+ * that contains one. yt-dlp spawns the binary from here during
126
+ * post-processing. An in-process JNI wrapper (e.g. FFmpegKit) is not enough.
127
+ */
128
+ location: string;
129
+ }
130
+
109
131
  export interface PlaylistOptions {
110
132
  enabled?: boolean;
111
133
  start?: number;
@@ -127,7 +149,17 @@ export interface DownloadOptions {
127
149
  /** Raw yt-dlp format expression, e.g. `best`, `bestvideo+bestaudio`. */
128
150
  format?: string;
129
151
  output?: OutputOptions;
152
+ /**
153
+ * Set to `true` to request merging (e.g. with `format: 'bestvideo+bestaudio'`).
154
+ * Merging requires FFmpeg — see `ffmpeg`.
155
+ */
130
156
  merge?: boolean;
157
+ /**
158
+ * Optional FFmpeg integration. When unset, FFmpeg-dependent features are
159
+ * rejected with `PROCESSING_FAILED`. When set, `bestvideo+bestaudio` and
160
+ * other post-processing become available.
161
+ */
162
+ ffmpeg?: FfmpegOptions;
131
163
  subtitles?: SubtitleOptions;
132
164
  cookies?: CookieOptions;
133
165
  /** Additional HTTP headers. Validated; secrets are never logged. */
@@ -188,6 +220,20 @@ export interface Subscription {
188
220
  export interface DownloadTask {
189
221
  id: string;
190
222
  cancel(): Promise<void>;
223
+ /**
224
+ * Pause an in-flight download. yt-dlp aborts on the next progress tick and
225
+ * leaves its `.part` file on disk; the task remains registered with status
226
+ * `paused`. Resolves `false` if the task is unknown, already paused, or
227
+ * already finished.
228
+ */
229
+ pause(): Promise<boolean>;
230
+ /**
231
+ * Resume a paused download with the exact same options. yt-dlp continues
232
+ * from the `.part` file by default (byte-range where the source supports it,
233
+ * otherwise the file restarts). Resolves `false` if the task is unknown or
234
+ * not paused.
235
+ */
236
+ resume(): Promise<boolean>;
191
237
  getStatus(): Promise<DownloadStatus>;
192
238
  getProgress(): Promise<DownloadProgress | null>;
193
239
  addListener(event: 'progress', listener: (progress: DownloadProgress) => void): Subscription;