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.
- package/CHANGELOG.md +54 -1
- package/README.md +422 -267
- package/android/src/main/AndroidManifest.xml +20 -1
- package/android/src/main/java/expo/modules/ytdlp/ExpoYtDlpModule.kt +8 -0
- package/android/src/main/java/expo/modules/ytdlp/YtDlpDownloadManager.kt +177 -16
- package/android/src/main/java/expo/modules/ytdlp/YtDlpEngine.kt +35 -4
- package/android/src/main/java/expo/modules/ytdlp/YtDlpForegroundService.kt +241 -0
- package/android/src/main/java/expo/modules/ytdlp/YtDlpTask.kt +44 -2
- package/build/ExpoYtDlpModule.d.ts +2 -0
- package/build/ExpoYtDlpModule.d.ts.map +1 -1
- package/build/ExpoYtDlpModule.js.map +1 -1
- package/build/YtDlp.d.ts +10 -0
- package/build/YtDlp.d.ts.map +1 -1
- package/build/YtDlp.js +22 -77
- package/build/YtDlp.js.map +1 -1
- package/build/constants.d.ts +1 -4
- package/build/constants.d.ts.map +1 -1
- package/build/constants.js +2 -1
- package/build/constants.js.map +1 -1
- package/build/downloadTask.d.ts +2 -0
- package/build/downloadTask.d.ts.map +1 -1
- package/build/downloadTask.js +7 -0
- package/build/downloadTask.js.map +1 -1
- package/build/errors.js +3 -3
- package/build/errors.js.map +1 -1
- package/build/index.d.ts +3 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -1
- package/build/index.js.map +1 -1
- package/build/mappers.js +3 -2
- package/build/mappers.js.map +1 -1
- package/build/serializers.d.ts +5 -0
- package/build/serializers.d.ts.map +1 -0
- package/build/serializers.js +96 -0
- package/build/serializers.js.map +1 -0
- package/build/types.d.ts +39 -1
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/package.json +7 -5
- package/src/ExpoYtDlpModule.ts +2 -0
- package/src/YtDlp.ts +21 -57
- package/src/__tests__/errors.test.ts +117 -0
- package/src/__tests__/mappers.test.ts +167 -0
- package/src/__tests__/serializers.test.ts +72 -0
- package/src/constants.ts +3 -1
- package/src/downloadTask.ts +9 -0
- package/src/errors.ts +3 -3
- package/src/index.ts +3 -1
- package/src/mappers.ts +3 -2
- package/src/serializers.ts +78 -0
- 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
package/src/downloadTask.ts
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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;
|