ytdlp-react-native 1.0.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 (63) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/LICENSE +21 -0
  3. package/README.md +267 -0
  4. package/android/build.gradle +25 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/expo/modules/ytdlp/ExpoYtDlpModule.kt +74 -0
  7. package/android/src/main/java/expo/modules/ytdlp/YtDlpDownloadManager.kt +97 -0
  8. package/android/src/main/java/expo/modules/ytdlp/YtDlpEngine.kt +277 -0
  9. package/android/src/main/java/expo/modules/ytdlp/YtDlpException.kt +14 -0
  10. package/android/src/main/java/expo/modules/ytdlp/YtDlpFileUtil.kt +89 -0
  11. package/android/src/main/java/expo/modules/ytdlp/YtDlpTask.kt +225 -0
  12. package/build/ExpoYtDlpModule.d.ts +35 -0
  13. package/build/ExpoYtDlpModule.d.ts.map +1 -0
  14. package/build/ExpoYtDlpModule.js +10 -0
  15. package/build/ExpoYtDlpModule.js.map +1 -0
  16. package/build/ExpoYtDlpModule.web.d.ts +11 -0
  17. package/build/ExpoYtDlpModule.web.d.ts.map +1 -0
  18. package/build/ExpoYtDlpModule.web.js +13 -0
  19. package/build/ExpoYtDlpModule.web.js.map +1 -0
  20. package/build/YtDlp.d.ts +23 -0
  21. package/build/YtDlp.d.ts.map +1 -0
  22. package/build/YtDlp.js +160 -0
  23. package/build/YtDlp.js.map +1 -0
  24. package/build/constants.d.ts +9 -0
  25. package/build/constants.d.ts.map +1 -0
  26. package/build/constants.js +9 -0
  27. package/build/constants.js.map +1 -0
  28. package/build/downloadTask.d.ts +30 -0
  29. package/build/downloadTask.d.ts.map +1 -0
  30. package/build/downloadTask.js +111 -0
  31. package/build/downloadTask.js.map +1 -0
  32. package/build/errors.d.ts +27 -0
  33. package/build/errors.d.ts.map +1 -0
  34. package/build/errors.js +111 -0
  35. package/build/errors.js.map +1 -0
  36. package/build/events.d.ts +20 -0
  37. package/build/events.d.ts.map +1 -0
  38. package/build/events.js +96 -0
  39. package/build/events.js.map +1 -0
  40. package/build/index.d.ts +14 -0
  41. package/build/index.d.ts.map +1 -0
  42. package/build/index.js +13 -0
  43. package/build/index.js.map +1 -0
  44. package/build/mappers.d.ts +15 -0
  45. package/build/mappers.d.ts.map +1 -0
  46. package/build/mappers.js +149 -0
  47. package/build/mappers.js.map +1 -0
  48. package/build/types.d.ts +183 -0
  49. package/build/types.d.ts.map +1 -0
  50. package/build/types.js +2 -0
  51. package/build/types.js.map +1 -0
  52. package/expo-module.config.json +6 -0
  53. package/package.json +68 -0
  54. package/src/ExpoYtDlpModule.ts +41 -0
  55. package/src/ExpoYtDlpModule.web.ts +15 -0
  56. package/src/YtDlp.ts +151 -0
  57. package/src/constants.ts +11 -0
  58. package/src/downloadTask.ts +159 -0
  59. package/src/errors.ts +133 -0
  60. package/src/events.ts +116 -0
  61. package/src/index.ts +17 -0
  62. package/src/mappers.ts +168 -0
  63. package/src/types.ts +219 -0
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Thin typed binding to the native `ExpoYtDlp` module.
3
+ *
4
+ * On unsupported platforms (web/iOS) the native module is absent; we detect
5
+ * this here and surface it at call time as `UNSUPPORTED_PLATFORM`, so a plain
6
+ * import never crashes.
7
+ */
8
+ import { NativeModule, requireOptionalNativeModule } from 'expo';
9
+
10
+ import type { DownloadEvent, DownloadStatus } from './types';
11
+
12
+ export interface DownloadTaskInfo {
13
+ taskId: string;
14
+ directory: string;
15
+ }
16
+
17
+ export interface DownloadStatusInfo {
18
+ taskId: string;
19
+ status: DownloadStatus;
20
+ percent?: number;
21
+ downloadedBytes?: number;
22
+ totalBytes?: number;
23
+ speedBytesPerSecond?: number;
24
+ etaSeconds?: number;
25
+ filename?: string;
26
+ }
27
+
28
+ export type ExpoYtDlpModuleEvents = {
29
+ downloadEvent: (event: DownloadEvent) => void;
30
+ };
31
+
32
+ export declare class ExpoYtDlpNativeModule extends NativeModule<ExpoYtDlpModuleEvents> {
33
+ getVersion(): Promise<string>;
34
+ extractInfo(url: string, options: Record<string, unknown>): Promise<string>;
35
+ startDownload(options: Record<string, unknown>): Promise<DownloadTaskInfo>;
36
+ cancelDownload(taskId: string): Promise<boolean>;
37
+ getDownloadStatus(taskId: string): Promise<DownloadStatusInfo | null>;
38
+ }
39
+
40
+ export const NativeExpoYtDlp: ExpoYtDlpNativeModule | null =
41
+ requireOptionalNativeModule<ExpoYtDlpNativeModule>('ExpoYtDlp');
@@ -0,0 +1,15 @@
1
+ import { NativeModule, registerWebModule } from 'expo';
2
+
3
+ import { SUPPORTED_PLATFORM_MESSAGE } from './constants';
4
+
5
+ /**
6
+ * Web placeholder. The package is Android-only (see AGENTS.md §3); every
7
+ * method throws a meaningful error instead of a silent no-op.
8
+ */
9
+ class ExpoYtDlpModule extends NativeModule {
10
+ getVersion(): Promise<string> {
11
+ throw new Error(SUPPORTED_PLATFORM_MESSAGE);
12
+ }
13
+ }
14
+
15
+ export default registerWebModule(ExpoYtDlpModule, 'ExpoYtDlp');
package/src/YtDlp.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { NativeExpoYtDlp, type ExpoYtDlpNativeModule } from './ExpoYtDlpModule';
2
+ import { LIBRARY_VERSION, SUPPORTED_PLATFORM_MESSAGE } from './constants';
3
+ import { YtDlpDownloadTask } from './downloadTask';
4
+ import { normalizeError, normalizeExtractionError, YtDlpError } from './errors';
5
+ import { mapVideoInfo } from './mappers';
6
+ import type {
7
+ CookieOptions,
8
+ DownloadOptions,
9
+ DownloadTask,
10
+ ExtractOptions,
11
+ Format,
12
+ VideoInfo,
13
+ YtDlpVersion,
14
+ } from './types';
15
+
16
+ function requireNative(): ExpoYtDlpNativeModule {
17
+ if (!NativeExpoYtDlp) {
18
+ throw new YtDlpError('UNSUPPORTED_PLATFORM', SUPPORTED_PLATFORM_MESSAGE);
19
+ }
20
+ return NativeExpoYtDlp;
21
+ }
22
+
23
+ function validateUrl(url: string | undefined): void {
24
+ if (!url || url.trim().length === 0) {
25
+ throw new YtDlpError('INVALID_URL', 'URL is required.');
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Read the yt-dlp version embedded in the native runtime plus this package's
31
+ * version. The two are independent (see AGENTS.md §35).
32
+ */
33
+ export async function getVersion(): Promise<YtDlpVersion> {
34
+ try {
35
+ const native = requireNative();
36
+ const ytDlp = await native.getVersion();
37
+ return { ytDlp, library: LIBRARY_VERSION };
38
+ } catch (cause) {
39
+ throw normalizeError(cause);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Extract normalized media information without downloading anything.
45
+ */
46
+ export async function extractInfo(url: string, options?: ExtractOptions): Promise<VideoInfo> {
47
+ validateUrl(url);
48
+ try {
49
+ const native = requireNative();
50
+ const json = await native.extractInfo(url, serializeExtractOptions(options));
51
+ return mapVideoInfo(JSON.parse(json));
52
+ } catch (cause) {
53
+ throw normalizeExtractionError(cause);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * List usable download formats. Internally reuses the same extraction as
59
+ * `extractInfo` to avoid redundant work (see AGENTS.md §34).
60
+ */
61
+ export async function getFormats(url: string, options?: ExtractOptions): Promise<Format[]> {
62
+ const info = await extractInfo(url, options);
63
+ return info.formats;
64
+ }
65
+
66
+ /**
67
+ * Start a download. Resolves with a [DownloadTask] immediately; progress and
68
+ * the final result arrive through the task's listeners (AGENTS.md §12).
69
+ */
70
+ export async function download(options: DownloadOptions): Promise<DownloadTask> {
71
+ validateUrl(options.url);
72
+ try {
73
+ const native = requireNative();
74
+ const info = await native.startDownload(serializeDownloadOptions(options));
75
+ return new YtDlpDownloadTask(native, info.taskId);
76
+ } catch (cause) {
77
+ throw normalizeError(cause);
78
+ }
79
+ }
80
+
81
+ /** Cancel a download by task id, e.g. after app re-creation (AGENTS.md §22). */
82
+ export async function cancel(taskId: string): Promise<boolean> {
83
+ try {
84
+ const native = requireNative();
85
+ return await native.cancelDownload(taskId);
86
+ } catch (cause) {
87
+ throw normalizeError(cause);
88
+ }
89
+ }
90
+
91
+ function serializeExtractOptions(options: ExtractOptions | undefined): Record<string, unknown> {
92
+ if (!options) return {};
93
+ const out: Record<string, unknown> = {};
94
+ if (options.cookies) out.cookies = serializeCookies(options.cookies);
95
+ if (options.proxy) out.proxy = options.proxy;
96
+ if (options.userAgent || options.headers) {
97
+ const headers: Record<string, string> = { ...(options.headers ?? {}) };
98
+ if (options.userAgent) headers['User-Agent'] = options.userAgent;
99
+ out.headers = headers;
100
+ }
101
+ return out;
102
+ }
103
+
104
+ function serializeDownloadOptions(options: DownloadOptions): Record<string, unknown> {
105
+ const out: Record<string, unknown> = { url: options.url };
106
+ if (options.format) out.format = options.format;
107
+ if (options.output) {
108
+ const output: Record<string, unknown> = {};
109
+ if (options.output.directory) output.directory = options.output.directory;
110
+ if (options.output.filename) output.filename = options.output.filename;
111
+ if (Object.keys(output).length > 0) out.output = output;
112
+ }
113
+ if (options.merge != null) out.merge = options.merge;
114
+ if (options.cookies) out.cookies = serializeCookies(options.cookies);
115
+ if (options.userAgent || options.headers) {
116
+ const headers: Record<string, string> = { ...(options.headers ?? {}) };
117
+ if (options.userAgent) headers['User-Agent'] = options.userAgent;
118
+ out.headers = headers;
119
+ }
120
+ if (options.referer) out.referer = options.referer;
121
+ if (options.proxy) out.proxy = options.proxy;
122
+ if (options.playlist) {
123
+ const playlist: Record<string, unknown> = {};
124
+ if (options.playlist.enabled != null) playlist.enabled = options.playlist.enabled;
125
+ if (options.playlist.start != null) playlist.start = options.playlist.start;
126
+ if (options.playlist.end != null) playlist.end = options.playlist.end;
127
+ if (Object.keys(playlist).length > 0) out.playlist = playlist;
128
+ }
129
+ if (options.network) {
130
+ const network: Record<string, unknown> = {};
131
+ if (options.network.timeout != null) network.timeout = options.network.timeout;
132
+ if (options.network.retries != null) network.retries = options.network.retries;
133
+ if (Object.keys(network).length > 0) out.network = network;
134
+ }
135
+ if (options.subtitles) {
136
+ const subtitles: Record<string, unknown> = {};
137
+ if (options.subtitles.enabled != null) subtitles.enabled = options.subtitles.enabled;
138
+ if (options.subtitles.languages?.length) subtitles.languages = options.subtitles.languages;
139
+ if (options.subtitles.autoGenerated != null)
140
+ subtitles.autoGenerated = options.subtitles.autoGenerated;
141
+ if (Object.keys(subtitles).length > 0) out.subtitles = subtitles;
142
+ }
143
+ return out;
144
+ }
145
+
146
+ function serializeCookies(cookies: CookieOptions): Record<string, unknown> {
147
+ if (cookies.source === 'file' && cookies.path) {
148
+ return { path: cookies.path };
149
+ }
150
+ throw new YtDlpError('INVALID_URL', 'Cookies must reference a cookies file path.');
151
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Package-level constants.
3
+ */
4
+ export const LIBRARY_VERSION = '0.1.0';
5
+
6
+ export const SUPPORTED_PLATFORM = 'android';
7
+
8
+ export const SUPPORTED_PLATFORM_MESSAGE = `${'expo-ytdlp-native'} is currently supported on ${SUPPORTED_PLATFORM} only.`;
9
+
10
+ /** yt-dlp output template used when the caller does not provide a filename. */
11
+ export const DEFAULT_FILENAME_TEMPLATE = '%(title)s.%(ext)s';
@@ -0,0 +1,159 @@
1
+ /**
2
+ * JS-side `DownloadTask` implementation.
3
+ *
4
+ * Keeps this module dependency-light: all per-task state lives in the native
5
+ * task registry; the JS object only relays events and forwards commands
6
+ * (AGENTS.md §17, §47).
7
+ */
8
+ import type { DownloadStatusInfo, ExpoYtDlpNativeModule } from './ExpoYtDlpModule';
9
+ import { YtDlpError } from './errors';
10
+ import { attachTaskListeners } from './events';
11
+ import type {
12
+ DownloadProgress,
13
+ DownloadResult,
14
+ DownloadStateEvent,
15
+ DownloadStatus,
16
+ DownloadTask,
17
+ Subscription,
18
+ } from './types';
19
+
20
+ type Listener<T> = (value: T) => void;
21
+
22
+ interface TaskListenerSets {
23
+ progress: Set<Listener<DownloadProgress>>;
24
+ state: Set<Listener<DownloadStateEvent>>;
25
+ completed: Set<Listener<DownloadResult>>;
26
+ error: Set<Listener<YtDlpError>>;
27
+ }
28
+
29
+ export class YtDlpDownloadTask implements DownloadTask {
30
+ readonly id: string;
31
+
32
+ private readonly native: ExpoYtDlpNativeModule;
33
+ private currentStatus: DownloadStatus = 'queued';
34
+ private finalized = false;
35
+ private readonly listeners: TaskListenerSets = {
36
+ progress: new Set(),
37
+ state: new Set(),
38
+ completed: new Set(),
39
+ error: new Set(),
40
+ };
41
+ private subscriptions: Subscription[] = [];
42
+
43
+ constructor(native: ExpoYtDlpNativeModule, id: string) {
44
+ this.native = native;
45
+ this.id = id;
46
+ this.subscriptions = attachTaskListeners(id, {
47
+ progress: (progress) => {
48
+ this.currentStatus = progress.status;
49
+ this.listeners.progress.forEach((listener) => listener(progress));
50
+ },
51
+ state: (state) => {
52
+ this.currentStatus = state.status;
53
+ this.listeners.state.forEach((listener) => listener(state));
54
+ },
55
+ completed: (result) => {
56
+ this.finalized = true;
57
+ this.currentStatus = 'completed';
58
+ this.teardown();
59
+ this.listeners.completed.forEach((listener) => listener(result));
60
+ },
61
+ cancelled: () => {
62
+ this.finalized = true;
63
+ this.currentStatus = 'cancelled';
64
+ this.teardown();
65
+ },
66
+ error: (error) => {
67
+ this.finalized = true;
68
+ this.currentStatus = 'failed';
69
+ this.teardown();
70
+ this.listeners.error.forEach((listener) => listener(error));
71
+ },
72
+ });
73
+ }
74
+
75
+ async cancel(): Promise<void> {
76
+ await this.native.cancelDownload(this.id);
77
+ }
78
+
79
+ async getStatus(): Promise<DownloadStatus> {
80
+ if (this.finalized) return this.currentStatus;
81
+ const info = await this.native.getDownloadStatus(this.id);
82
+ if (info?.status) this.currentStatus = info.status;
83
+ return this.currentStatus;
84
+ }
85
+
86
+ async getProgress(): Promise<DownloadProgress | null> {
87
+ const info = await this.native.getDownloadStatus(this.id);
88
+ if (!info) return null;
89
+ return mapStatusInfo(this.id, this.currentStatus, info);
90
+ }
91
+
92
+ addListener(event: 'progress', listener: Listener<DownloadProgress>): Subscription;
93
+ addListener(event: 'state', listener: Listener<DownloadStateEvent>): Subscription;
94
+ addListener(event: 'completed', listener: Listener<DownloadResult>): Subscription;
95
+ addListener(event: 'error', listener: Listener<YtDlpError>): Subscription;
96
+ addListener(
97
+ event: 'progress' | 'state' | 'completed' | 'error',
98
+ listener:
99
+ | Listener<DownloadProgress>
100
+ | Listener<DownloadStateEvent>
101
+ | Listener<DownloadResult>
102
+ | Listener<YtDlpError>
103
+ ): Subscription {
104
+ const set =
105
+ event === 'progress'
106
+ ? this.listeners.progress
107
+ : event === 'state'
108
+ ? this.listeners.state
109
+ : event === 'completed'
110
+ ? this.listeners.completed
111
+ : this.listeners.error;
112
+ set.add(listener as never);
113
+ return {
114
+ remove: () => {
115
+ set.delete(listener as never);
116
+ },
117
+ };
118
+ }
119
+
120
+ private teardown() {
121
+ this.subscriptions.forEach((subscription) => subscription.remove());
122
+ this.subscriptions = [];
123
+ }
124
+ }
125
+
126
+ function mapStatusInfo(
127
+ taskId: string,
128
+ fallbackStatus: DownloadStatus,
129
+ info: DownloadStatusInfo
130
+ ): DownloadProgress {
131
+ return {
132
+ taskId,
133
+ status: (info.status ?? fallbackStatus) as DownloadStatus,
134
+ phase: phaseOf(info.status),
135
+ percent: finiteOrUndefined(info.percent),
136
+ downloadedBytes: finiteOrUndefined(info.downloadedBytes),
137
+ totalBytes: finiteOrUndefined(info.totalBytes),
138
+ speedBytesPerSecond: finiteOrUndefined(info.speedBytesPerSecond),
139
+ etaSeconds: finiteOrUndefined(info.etaSeconds),
140
+ filename: info.filename,
141
+ };
142
+ }
143
+
144
+ function phaseOf(status: DownloadStatus): DownloadProgress['phase'] {
145
+ switch (status) {
146
+ case 'downloading':
147
+ return 'downloading';
148
+ case 'completed':
149
+ case 'processing':
150
+ return 'processing';
151
+ default:
152
+ return 'extracting';
153
+ }
154
+ }
155
+
156
+ function finiteOrUndefined(value: number | undefined): number | undefined {
157
+ if (value === undefined || value === null) return undefined;
158
+ return Number.isFinite(value) ? value : undefined;
159
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,133 @@
1
+ import type { YtDlpErrorCode } from './types';
2
+
3
+ /** Prefix used by the Kotlin layer to carry error codes across the bridge. */
4
+ const NATIVE_PREFIX = 'YTD_NATIVE|';
5
+
6
+ export class YtDlpError extends Error {
7
+ readonly code: YtDlpErrorCode;
8
+ readonly taskId?: string;
9
+ readonly cause?: unknown;
10
+
11
+ constructor(
12
+ code: YtDlpErrorCode,
13
+ message: string,
14
+ options?: { taskId?: string; cause?: unknown }
15
+ ) {
16
+ super(message);
17
+ this.name = 'YtDlpError';
18
+ this.code = code;
19
+ if (options?.taskId) this.taskId = options.taskId;
20
+ if (options?.cause !== undefined) this.cause = options.cause;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Normalizes anything thrown by the native layer into a `YtDlpError`.
26
+ * Unknown/missing codes map to `UNKNOWN`; raw stack traces are never surfaced.
27
+ */
28
+ export function normalizeError(
29
+ cause: unknown,
30
+ fallbackMessage = 'Unknown yt-dlp error'
31
+ ): YtDlpError {
32
+ if (cause instanceof YtDlpError) return cause;
33
+
34
+ if (cause instanceof Error) {
35
+ const parsed = parseNativeMessage(cause.message);
36
+ if (parsed) {
37
+ return new YtDlpError(parsed.code, parsed.message, { cause });
38
+ }
39
+ return new YtDlpError('UNKNOWN', cause.message || fallbackMessage, { cause });
40
+ }
41
+
42
+ if (typeof cause === 'string') {
43
+ const parsed = parseNativeMessage(cause);
44
+ if (parsed) return new YtDlpError(parsed.code, parsed.message);
45
+ return new YtDlpError('UNKNOWN', cause || fallbackMessage);
46
+ }
47
+
48
+ return new YtDlpError('UNKNOWN', fallbackMessage, { cause });
49
+ }
50
+
51
+ function parseNativeMessage(message: string): { code: YtDlpErrorCode; message: string } | null {
52
+ if (!message.startsWith(NATIVE_PREFIX)) return null;
53
+ const rest = message.slice(NATIVE_PREFIX.length);
54
+ const separator = rest.indexOf('|');
55
+ if (separator === -1) return null;
56
+ const rawCode = rest.slice(0, separator);
57
+ const text = rest.slice(separator + 1);
58
+ const code = toKnownCode(rawCode);
59
+ return { code, message: text || rawCode };
60
+ }
61
+
62
+ function toKnownCode(rawCode: string): YtDlpErrorCode {
63
+ const known: YtDlpErrorCode[] = [
64
+ 'INVALID_URL',
65
+ 'EXTRACTION_FAILED',
66
+ 'DOWNLOAD_FAILED',
67
+ 'CANCELLED',
68
+ 'FORMAT_UNAVAILABLE',
69
+ 'NETWORK_ERROR',
70
+ 'AUTHENTICATION_REQUIRED',
71
+ 'GEO_RESTRICTED',
72
+ 'PRIVATE_CONTENT',
73
+ 'AGE_RESTRICTED',
74
+ 'PROCESSING_FAILED',
75
+ 'STORAGE_ERROR',
76
+ 'INIT_FAILED',
77
+ 'UNSUPPORTED_PLATFORM',
78
+ 'UNKNOWN',
79
+ ];
80
+ if ((known as readonly string[]).includes(rawCode)) return rawCode as YtDlpErrorCode;
81
+ return 'UNKNOWN';
82
+ }
83
+
84
+ /**
85
+ * Builds a `YtDlpError` from a code+message pair received from native events.
86
+ * Unknown codes degrade to `UNKNOWN` (AGENTS.md §23).
87
+ */
88
+ export function fromNativeCode(code: string, message: string): YtDlpError {
89
+ return new YtDlpError(toKnownCode(code), message);
90
+ }
91
+
92
+ /**
93
+ * Refines an extraction failure into a more specific code by matching the
94
+ * yt-dlp error message. Kept conservative: no match falls back to the
95
+ * normalized code.
96
+ */
97
+ export function normalizeExtractionError(
98
+ cause: unknown,
99
+ fallbackMessage = 'Failed to extract media information'
100
+ ): YtDlpError {
101
+ const base = normalizeError(cause, fallbackMessage);
102
+ if (base.code !== 'EXTRACTION_FAILED' && base.code !== 'UNKNOWN') return base;
103
+
104
+ const message = base.message.toLowerCase();
105
+ if (
106
+ /http error|connection|timed out|unreachable|name or service not known|failed to establish|reset by peer|couldn't connect|503|429/.test(
107
+ message
108
+ )
109
+ ) {
110
+ return new YtDlpError('NETWORK_ERROR', base.message, { cause: base.cause });
111
+ }
112
+ if (
113
+ message.includes('sign in to confirm') ||
114
+ message.includes('log in') ||
115
+ message.includes('sign up')
116
+ ) {
117
+ return new YtDlpError('AUTHENTICATION_REQUIRED', base.message, { cause: base.cause });
118
+ }
119
+ if (message.includes('private') || message.includes('members-only')) {
120
+ return new YtDlpError('PRIVATE_CONTENT', base.message, { cause: base.cause });
121
+ }
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
+ if (
126
+ message.includes('geographic') ||
127
+ message.includes('geo-') ||
128
+ message.includes('not available in your country')
129
+ ) {
130
+ return new YtDlpError('GEO_RESTRICTED', base.message, { cause: base.cause });
131
+ }
132
+ return base;
133
+ }
package/src/events.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Shared helpers for reacting to native `downloadEvent` emissions.
3
+ */
4
+ import { NativeExpoYtDlp } from './ExpoYtDlpModule';
5
+ import { fromNativeCode, type YtDlpError } from './errors';
6
+ import type {
7
+ DownloadEvent,
8
+ DownloadProgress,
9
+ DownloadResult,
10
+ DownloadStateEvent,
11
+ Subscription,
12
+ } from './types';
13
+
14
+ const EVENT_NAME = 'downloadEvent';
15
+
16
+ /**
17
+ * Returns a per-task subscription backed by the native event stream.
18
+ * `taskId` is used to filter events that belong to another task.
19
+ */
20
+ export function subscribeToTask(
21
+ taskId: string,
22
+ handler: (event: DownloadEvent) => void
23
+ ): Subscription {
24
+ return subscribeToDownloadEvents((event) => {
25
+ if (event.taskId === taskId) {
26
+ handler(event);
27
+ }
28
+ });
29
+ }
30
+
31
+ export function subscribeToDownloadEvents(handler: (event: DownloadEvent) => void): Subscription {
32
+ if (!NativeExpoYtDlp) {
33
+ return { remove: () => {} };
34
+ }
35
+ // The native module is itself an EventEmitter (Expo SDK 52+); the old
36
+ // `new NativeEventEmitter(module)` path requires `addListener`/`removeListeners`
37
+ // that Expo modules do not expose.
38
+ const subscription = NativeExpoYtDlp.addListener(EVENT_NAME, (event: DownloadEvent) => {
39
+ try {
40
+ handler(normalizeEvent(event));
41
+ } catch {
42
+ // Never let a listener crash the JS runtime.
43
+ }
44
+ });
45
+ return { remove: () => subscription.remove() };
46
+ }
47
+
48
+ /**
49
+ * Adds task-level listeners to a [DownloadTask] from a raw subscription
50
+ * factory. Keeps the event filtering in one place.
51
+ */
52
+ export function attachTaskListeners(
53
+ taskId: string,
54
+ listeners: {
55
+ progress?: (progress: DownloadProgress) => void;
56
+ state?: (state: DownloadStateEvent) => void;
57
+ completed?: (result: DownloadResult) => void;
58
+ cancelled?: () => void;
59
+ error?: (error: YtDlpError) => void;
60
+ }
61
+ ): Subscription[] {
62
+ const subs: Subscription[] = [];
63
+
64
+ subs.push(
65
+ subscribeToTask(taskId, (event) => {
66
+ switch (event.type) {
67
+ case 'progress':
68
+ if (event.progress && listeners.progress) listeners.progress(event.progress);
69
+ break;
70
+ case 'state':
71
+ if (listeners.state) listeners.state({ taskId: event.taskId, status: event.status });
72
+ break;
73
+ case 'completed':
74
+ if (event.result && listeners.completed) listeners.completed(event.result);
75
+ break;
76
+ case 'cancelled':
77
+ if (listeners.cancelled) listeners.cancelled();
78
+ if (listeners.state) listeners.state({ taskId: event.taskId, status: 'cancelled' });
79
+ break;
80
+ case 'error':
81
+ if (event.error) {
82
+ const error = fromNativeCode(event.error.code, event.error.message);
83
+ if (listeners.error) listeners.error(error);
84
+ }
85
+ break;
86
+ }
87
+ })
88
+ );
89
+
90
+ return subs;
91
+ }
92
+
93
+ function normalizeEvent(event: DownloadEvent): DownloadEvent {
94
+ if (event.type === 'progress' && event.progress) {
95
+ return {
96
+ ...event,
97
+ progress: {
98
+ taskId: event.taskId,
99
+ status: event.status,
100
+ phase: event.progress.phase,
101
+ percent: finiteOrUndefined(event.progress.percent),
102
+ downloadedBytes: finiteOrUndefined(event.progress.downloadedBytes),
103
+ totalBytes: finiteOrUndefined(event.progress.totalBytes),
104
+ speedBytesPerSecond: finiteOrUndefined(event.progress.speedBytesPerSecond),
105
+ etaSeconds: finiteOrUndefined(event.progress.etaSeconds),
106
+ filename: event.progress.filename,
107
+ },
108
+ };
109
+ }
110
+ return event;
111
+ }
112
+
113
+ function finiteOrUndefined(value: number | undefined): number | undefined {
114
+ if (value === undefined || value === null) return undefined;
115
+ return Number.isFinite(value) ? value : undefined;
116
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { cancel, download, extractInfo, getFormats, getVersion } from './YtDlp';
2
+ import { YtDlpError } from './errors';
3
+
4
+ export type * from './types';
5
+
6
+ /** Main SDK facade. */
7
+ export const YtDlp = {
8
+ getVersion,
9
+ extractInfo,
10
+ getFormats,
11
+ download,
12
+ cancel,
13
+ };
14
+
15
+ export { YtDlpError };
16
+
17
+ export default YtDlp;