umedia 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -138
- package/bin/umedia.js +25 -51
- package/index.d.ts +59 -111
- package/index.js +45 -189
- package/lib/download.js +159 -0
- package/lib/errors.js +30 -0
- package/lib/providers/instagram.js +69 -0
- package/lib/providers/itunes.js +47 -0
- package/lib/providers/reddit.js +97 -0
- package/lib/providers/tiktok.js +165 -0
- package/lib/providers/x.js +71 -0
- package/lib/providers/youtube.js +146 -0
- package/lib/providers/ytdlp.js +97 -0
- package/lib/quality.js +49 -0
- package/lib/registry.js +123 -0
- package/lib/util.js +93 -0
- package/package.json +12 -8
package/index.d.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
/** umedia —
|
|
2
|
-
|
|
3
|
-
export const DEFAULT_BASE_URL: string;
|
|
1
|
+
/** umedia — standalone media engine. Runs entirely on your machine: no hosted API, no keys, no bills. */
|
|
4
2
|
|
|
5
3
|
export type ErrorCode =
|
|
6
4
|
| "INVALID_REQUEST" | "INVALID_URL" | "SSRF_BLOCKED" | "AUTH_REQUIRED" | "AUTH_INVALID"
|
|
@@ -9,6 +7,7 @@ export type ErrorCode =
|
|
|
9
7
|
| "NOT_FOUND" | "INTERNAL";
|
|
10
8
|
|
|
11
9
|
export const Codes: Readonly<Record<ErrorCode, ErrorCode>>;
|
|
10
|
+
export const qualityLadder: readonly string[];
|
|
12
11
|
|
|
13
12
|
export class UMediaError extends Error {
|
|
14
13
|
name: "UMediaError";
|
|
@@ -18,14 +17,11 @@ export class UMediaError extends Error {
|
|
|
18
17
|
attempts: ProviderAttempt[];
|
|
19
18
|
details: unknown;
|
|
20
19
|
constructor(code: ErrorCode, message: string, opts?: {
|
|
21
|
-
status?: number;
|
|
22
|
-
requestId?: string | null;
|
|
23
|
-
attempts?: ProviderAttempt[];
|
|
24
|
-
details?: unknown;
|
|
20
|
+
status?: number; requestId?: string | null; attempts?: ProviderAttempt[]; details?: unknown;
|
|
25
21
|
});
|
|
26
22
|
}
|
|
27
23
|
|
|
28
|
-
/** Honest provider
|
|
24
|
+
/** Honest provider record — which adapter actually did the work (or why it couldn't). */
|
|
29
25
|
export interface ProviderAttempt {
|
|
30
26
|
provider: string;
|
|
31
27
|
status: "success" | "failed" | "skipped";
|
|
@@ -33,42 +29,35 @@ export interface ProviderAttempt {
|
|
|
33
29
|
error?: string | null;
|
|
34
30
|
}
|
|
35
31
|
|
|
36
|
-
/** One real, honestly-labeled rendition of a media item. */
|
|
37
32
|
export interface MediaVariant {
|
|
38
33
|
quality: string; // derived from ACTUAL source height — never upscaled
|
|
39
34
|
width?: number | null;
|
|
40
35
|
height?: number | null;
|
|
41
|
-
url?: string | null;
|
|
36
|
+
url?: string | null; // null when the network is bot-gated — never faked
|
|
42
37
|
mimeType?: string | null;
|
|
43
38
|
bitrateKbps?: number | null;
|
|
44
39
|
hasAudio?: boolean | null;
|
|
45
40
|
hasVideo?: boolean | null;
|
|
46
41
|
}
|
|
47
42
|
|
|
48
|
-
/** One media item. Multi-item posts yield ONE MediaObject per item, in order. */
|
|
49
43
|
export interface MediaObject {
|
|
50
44
|
type: "image" | "video" | "audio" | "short_video" | "gif";
|
|
51
45
|
index: number;
|
|
52
|
-
url: string;
|
|
46
|
+
url: string | null;
|
|
53
47
|
thumbnail?: string | null;
|
|
54
48
|
mimeType?: string | null;
|
|
55
49
|
width?: number | null;
|
|
56
50
|
height?: number | null;
|
|
57
51
|
duration?: number | null;
|
|
58
52
|
size?: number | null;
|
|
59
|
-
quality?: string | null;
|
|
53
|
+
quality?: string | null;
|
|
60
54
|
hasAudio?: boolean | null;
|
|
61
55
|
hasVideo?: boolean | null;
|
|
62
56
|
source?: string;
|
|
63
57
|
variants: MediaVariant[];
|
|
64
58
|
}
|
|
65
59
|
|
|
66
|
-
export interface MediaCounts {
|
|
67
|
-
images: number;
|
|
68
|
-
videos: number;
|
|
69
|
-
audios: number;
|
|
70
|
-
other: number;
|
|
71
|
-
}
|
|
60
|
+
export interface MediaCounts { images: number; videos: number; audios: number; other: number; }
|
|
72
61
|
|
|
73
62
|
export interface ResolveResult {
|
|
74
63
|
sourceUrl: string;
|
|
@@ -78,13 +67,15 @@ export interface ResolveResult {
|
|
|
78
67
|
thumbnail?: string | null;
|
|
79
68
|
itemCount: number;
|
|
80
69
|
counts: MediaCounts;
|
|
81
|
-
/** true only when
|
|
70
|
+
/** true only when an item cap was hit — never silent truncation */
|
|
82
71
|
truncated: boolean;
|
|
83
72
|
media: MediaObject[];
|
|
73
|
+
/** YouTube-specific honesty about stream URL availability on this network */
|
|
74
|
+
streamUrlsAvailable?: boolean;
|
|
75
|
+
streamAccess?: string;
|
|
84
76
|
[k: string]: unknown;
|
|
85
77
|
}
|
|
86
78
|
|
|
87
|
-
/** Provider-selection transparency (multi-source engine visibility). */
|
|
88
79
|
export interface ResolveMeta {
|
|
89
80
|
attempts: ProviderAttempt[];
|
|
90
81
|
finalProvider?: string | null;
|
|
@@ -96,137 +87,94 @@ export interface SearchItem {
|
|
|
96
87
|
title?: string;
|
|
97
88
|
author?: string;
|
|
98
89
|
pageUrl?: string;
|
|
99
|
-
url?: string;
|
|
100
90
|
thumbnail?: string | null;
|
|
101
91
|
duration?: number | null;
|
|
102
92
|
mediaType?: string;
|
|
103
93
|
source?: string;
|
|
104
|
-
quality?: string | null;
|
|
105
94
|
explicit?: boolean;
|
|
106
95
|
/** real 30s clip / trailer / official embed — `none` when nothing honest exists */
|
|
107
96
|
previewUrl?: string | null;
|
|
108
97
|
previewKind?: "clip30s" | "trailer" | "embed" | "none";
|
|
109
|
-
providers?: ProviderAttempt[];
|
|
110
98
|
[k: string]: unknown;
|
|
111
99
|
}
|
|
112
100
|
|
|
113
|
-
export interface
|
|
114
|
-
items: SearchItem[];
|
|
115
|
-
adultFilter?: unknown;
|
|
116
|
-
[k: string]: unknown;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export interface DownloadFile {
|
|
101
|
+
export interface DownloadedFile {
|
|
120
102
|
name: string;
|
|
121
|
-
|
|
122
|
-
|
|
103
|
+
path: string;
|
|
104
|
+
size: number;
|
|
105
|
+
ext: string;
|
|
123
106
|
mimeType?: string | null;
|
|
124
107
|
index: number;
|
|
125
|
-
|
|
108
|
+
quality?: string | null;
|
|
126
109
|
}
|
|
127
110
|
|
|
128
|
-
/** Honest download outcome — quality truth lives here. */
|
|
129
111
|
export interface DownloadResult {
|
|
130
|
-
title?: string;
|
|
131
|
-
source?: string;
|
|
132
|
-
duration?: number | null;
|
|
133
|
-
width?: number | null;
|
|
134
|
-
height?: number | null;
|
|
135
|
-
requestedQuality?: string;
|
|
136
|
-
selectedQuality?: string | null;
|
|
137
|
-
fallback?: boolean | null;
|
|
138
|
-
mimeType?: string | null;
|
|
139
|
-
file?: DownloadFile | null;
|
|
140
|
-
files?: DownloadFile[];
|
|
141
|
-
itemCount?: number;
|
|
142
|
-
requestedItems?: number;
|
|
143
|
-
truncated?: boolean;
|
|
144
|
-
failedItems?: Array<{ index?: number; type?: string; url?: string; error?: string }>;
|
|
145
|
-
completedAt?: number | null;
|
|
146
|
-
[k: string]: unknown;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export interface DownloadJob {
|
|
150
|
-
jobId: string;
|
|
151
112
|
url: string;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export interface DownloadHandle {
|
|
166
|
-
jobId: string;
|
|
167
|
-
status: string;
|
|
168
|
-
progress: number;
|
|
169
|
-
links: { status: string; file: string; cancel: string };
|
|
113
|
+
title?: string | null;
|
|
114
|
+
source?: string | null;
|
|
115
|
+
requestedQuality: string;
|
|
116
|
+
selectedQuality: string | null;
|
|
117
|
+
fallback: boolean;
|
|
118
|
+
files: DownloadedFile[];
|
|
119
|
+
itemCount: number;
|
|
120
|
+
requestedItems: number;
|
|
121
|
+
truncated: boolean;
|
|
122
|
+
failedItems: Array<{ index?: number; type?: string; url?: string; error?: string }>;
|
|
123
|
+
note?: string;
|
|
170
124
|
}
|
|
171
125
|
|
|
172
|
-
/** Normalized envelope meta — every response carries meta.requestId. */
|
|
173
126
|
export interface Meta {
|
|
174
127
|
requestId: string;
|
|
175
|
-
cached: boolean;
|
|
176
128
|
source?: string | null;
|
|
177
129
|
timestamp: string;
|
|
178
|
-
|
|
130
|
+
elapsedMs?: number;
|
|
179
131
|
}
|
|
180
132
|
|
|
181
|
-
export interface Envelope<T> {
|
|
182
|
-
data: T;
|
|
183
|
-
meta: Meta;
|
|
184
|
-
}
|
|
133
|
+
export interface Envelope<T> { data: T; meta: Meta; }
|
|
185
134
|
|
|
186
135
|
export interface UMediaOptions {
|
|
187
|
-
/**
|
|
188
|
-
|
|
189
|
-
baseUrl?: string;
|
|
136
|
+
/** default directory for downloads (default "./umedia-downloads") */
|
|
137
|
+
downloadDir?: string;
|
|
190
138
|
timeoutMs?: number;
|
|
191
|
-
fetch?: typeof fetch;
|
|
192
139
|
}
|
|
193
140
|
|
|
194
141
|
export class UMedia {
|
|
195
142
|
constructor(opts?: UMediaOptions);
|
|
196
|
-
|
|
197
|
-
baseUrl: string;
|
|
198
|
-
timeoutMs: number;
|
|
143
|
+
downloadDir: string;
|
|
199
144
|
|
|
200
145
|
search(p?: {
|
|
201
|
-
q?: string;
|
|
202
|
-
query?: string;
|
|
146
|
+
q?: string; query?: string;
|
|
203
147
|
type?: "video" | "music" | "movie" | "short_video";
|
|
204
|
-
limit?: number;
|
|
205
|
-
|
|
206
|
-
}): Promise<Envelope<SearchResponse>>;
|
|
148
|
+
limit?: number; adult?: boolean;
|
|
149
|
+
}): Promise<Envelope<{ items: SearchItem[]; adultFilter?: unknown }> & { engine: { attempts: ProviderAttempt[] } }>;
|
|
207
150
|
|
|
208
|
-
resolve(url: string): Promise<{ data: ResolveResult; engine
|
|
151
|
+
resolve(url: string): Promise<{ data: ResolveResult; engine: ResolveMeta }>;
|
|
209
152
|
|
|
210
|
-
|
|
153
|
+
download(p: {
|
|
211
154
|
url: string;
|
|
212
|
-
type?: "video" | "music" | "movie" | "short_video";
|
|
213
155
|
/** original | best | 2160p … 144p | audio */
|
|
214
156
|
quality?: string;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
157
|
+
type?: "video" | "music" | "movie" | "short_video";
|
|
158
|
+
dir?: string;
|
|
159
|
+
zip?: boolean;
|
|
160
|
+
onProgress?: (done: number, total: number) => void;
|
|
161
|
+
}): Promise<Envelope<DownloadResult> & { engine?: ResolveMeta }>;
|
|
162
|
+
|
|
163
|
+
downloadWithYtdlp(p: {
|
|
164
|
+
url: string; quality?: string; dir?: string;
|
|
165
|
+
onProgress?: (done: number, total: number) => void;
|
|
166
|
+
}): Promise<Envelope<DownloadResult>>;
|
|
167
|
+
|
|
168
|
+
capabilities(): Promise<Envelope<{
|
|
169
|
+
platforms: Array<{ platform: string; search: boolean; resolve: boolean; download: boolean | string; notes?: string }>;
|
|
170
|
+
qualityLadder: string[];
|
|
171
|
+
}>>;
|
|
172
|
+
|
|
173
|
+
status(): Promise<Envelope<{
|
|
174
|
+
overall: string;
|
|
175
|
+
runtime: { node: string; platform: string };
|
|
176
|
+
providers: Array<{ name: string; state: string; via: string }>;
|
|
177
|
+
}>>;
|
|
230
178
|
}
|
|
231
179
|
|
|
232
180
|
export default UMedia;
|
package/index.js
CHANGED
|
@@ -1,219 +1,75 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* umedia —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* umedia — standalone media engine. Runs entirely on YOUR machine:
|
|
3
|
+
* no hosted API, no keys, no rate limits, no server bills.
|
|
4
|
+
*
|
|
5
|
+
* Search · Resolve · Download — honest quality labels, real previews, complete galleries.
|
|
5
6
|
*/
|
|
7
|
+
import { rid } from "./lib/util.js";
|
|
8
|
+
import * as registry from "./lib/registry.js";
|
|
9
|
+
import { download, downloadWithYtdlp } from "./lib/download.js";
|
|
10
|
+
import { UMediaError, Codes } from "./lib/errors.js";
|
|
6
11
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
/** Error codes returned in `error.code` (stable API contract). */
|
|
10
|
-
export const Codes = Object.freeze({
|
|
11
|
-
INVALID_REQUEST: "INVALID_REQUEST",
|
|
12
|
-
INVALID_URL: "INVALID_URL",
|
|
13
|
-
SSRF_BLOCKED: "SSRF_BLOCKED",
|
|
14
|
-
AUTH_REQUIRED: "AUTH_REQUIRED",
|
|
15
|
-
AUTH_INVALID: "AUTH_INVALID",
|
|
16
|
-
RATE_LIMITED: "RATE_LIMITED",
|
|
17
|
-
MEDIA_NOT_FOUND: "MEDIA_NOT_FOUND",
|
|
18
|
-
CONTENT_PRIVATE: "CONTENT_PRIVATE",
|
|
19
|
-
ACCESS_RESTRICTED: "ACCESS_RESTRICTED",
|
|
20
|
-
QUALITY_UNAVAILABLE: "QUALITY_UNAVAILABLE",
|
|
21
|
-
PROVIDER_ERROR: "PROVIDER_ERROR",
|
|
22
|
-
PROVIDER_UNAVAILABLE: "PROVIDER_UNAVAILABLE",
|
|
23
|
-
TIMEOUT: "TIMEOUT",
|
|
24
|
-
NOT_FOUND: "NOT_FOUND",
|
|
25
|
-
INTERNAL: "INTERNAL",
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
export class UMediaError extends Error {
|
|
29
|
-
/**
|
|
30
|
-
* @param {string} code one of {@link Codes}
|
|
31
|
-
* @param {string} message human-readable, honest failure reason
|
|
32
|
-
* @param {{status?: number, requestId?: string|null, attempts?: object[]}} [opts]
|
|
33
|
-
*/
|
|
34
|
-
constructor(code, message, opts = {}) {
|
|
35
|
-
super(message);
|
|
36
|
-
this.name = "UMediaError";
|
|
37
|
-
this.code = code || Codes.INTERNAL;
|
|
38
|
-
this.status = opts.status ?? 0;
|
|
39
|
-
this.requestId = opts.requestId ?? null;
|
|
40
|
-
this.attempts = opts.attempts ?? [];
|
|
41
|
-
this.details = opts.details ?? null;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
12
|
+
export { UMediaError, Codes };
|
|
13
|
+
export { LADDER as qualityLadder } from "./lib/quality.js";
|
|
44
14
|
|
|
45
15
|
export class UMedia {
|
|
46
|
-
/**
|
|
47
|
-
* @param {{apiKey?: string, baseUrl?: string, timeoutMs?: number, fetch?: typeof fetch}} [opts]
|
|
48
|
-
* apiKey — optional; sends `X-API-Key` (higher rate limits). Never required for public reads.
|
|
49
|
-
*/
|
|
16
|
+
/** @param {{downloadDir?: string, timeoutMs?: number}} [opts] */
|
|
50
17
|
constructor(opts = {}) {
|
|
51
|
-
|
|
52
|
-
this.
|
|
53
|
-
this.baseUrl = String(baseUrl).replace(/\/+$/, "");
|
|
54
|
-
this.timeoutMs = timeoutMs;
|
|
55
|
-
this._fetch = fetchImpl || globalThis.fetch;
|
|
56
|
-
if (typeof this._fetch !== "function") {
|
|
57
|
-
throw new TypeError("umedia: no fetch available — use Node 18+ or pass { fetch }");
|
|
58
|
-
}
|
|
18
|
+
this.downloadDir = opts.downloadDir || "./umedia-downloads";
|
|
19
|
+
this.timeoutMs = opts.timeoutMs ?? 120_000;
|
|
59
20
|
}
|
|
60
21
|
|
|
61
|
-
async _call(method, path, body) {
|
|
62
|
-
const headers = { accept: "application/json" };
|
|
63
|
-
if (this.apiKey) headers["x-api-key"] = this.apiKey;
|
|
64
|
-
const init = { method, headers };
|
|
65
|
-
if (body !== undefined) {
|
|
66
|
-
headers["content-type"] = "application/json";
|
|
67
|
-
init.body = JSON.stringify(body);
|
|
68
|
-
}
|
|
69
|
-
const ctl = new AbortController();
|
|
70
|
-
const timer = setTimeout(() => ctl.abort(), this.timeoutMs);
|
|
71
|
-
init.signal = ctl.signal;
|
|
72
|
-
|
|
73
|
-
let res;
|
|
74
|
-
try {
|
|
75
|
-
res = await this._fetch(this.baseUrl + path, init);
|
|
76
|
-
} catch (e) {
|
|
77
|
-
const timedOut = e && (e.name === "AbortError" || e.code === "ABORT_ERR");
|
|
78
|
-
throw new UMediaError(
|
|
79
|
-
timedOut ? Codes.TIMEOUT : Codes.PROVIDER_UNAVAILABLE,
|
|
80
|
-
timedOut ? `Request timed out after ${this.timeoutMs}ms` : `Network error: ${e.message}`,
|
|
81
|
-
);
|
|
82
|
-
} finally {
|
|
83
|
-
clearTimeout(timer);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
let env;
|
|
87
|
-
try {
|
|
88
|
-
env = await res.json();
|
|
89
|
-
} catch {
|
|
90
|
-
throw new UMediaError(Codes.INTERNAL, `Non-JSON response (HTTP ${res.status})`, { status: res.status });
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!res.ok || env?.success === false) {
|
|
94
|
-
const err = env?.error || {};
|
|
95
|
-
const code = err.code
|
|
96
|
-
|| (res.status === 429 ? Codes.RATE_LIMITED
|
|
97
|
-
: res.status === 401 ? Codes.AUTH_REQUIRED
|
|
98
|
-
: res.status === 404 ? Codes.NOT_FOUND
|
|
99
|
-
: Codes.INTERNAL);
|
|
100
|
-
throw new UMediaError(code, err.message || `HTTP ${res.status}`, {
|
|
101
|
-
status: res.status,
|
|
102
|
-
requestId: env?.meta?.requestId ?? null,
|
|
103
|
-
attempts: err.attempts || env?.meta?.attempts || [],
|
|
104
|
-
details: err.details ?? null,
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
return { data: env.data, meta: env.meta };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ---------- core: search / resolve ----------
|
|
111
|
-
|
|
112
22
|
/**
|
|
113
|
-
*
|
|
23
|
+
* Multi-source search. Music leads with REAL 30s previews (iTunes), then YouTube.
|
|
114
24
|
* @param {{q?: string, query?: string, type?: "video"|"music"|"movie"|"short_video", limit?: number, adult?: boolean}} [p]
|
|
115
|
-
* @returns {Promise<{data: {items: object[], adultFilter?: object}, meta: object}>}
|
|
116
|
-
*/
|
|
117
|
-
search(p = {}) {
|
|
118
|
-
const q = p.q ?? p.query ?? "";
|
|
119
|
-
const type = p.type ?? "video";
|
|
120
|
-
const limit = Math.max(1, Math.min(25, p.limit ?? 12));
|
|
121
|
-
const adult = p.adult ? "true" : "false";
|
|
122
|
-
return this._call(
|
|
123
|
-
"GET",
|
|
124
|
-
`/api/v1/social/search?q=${encodeURIComponent(q)}&type=${encodeURIComponent(type)}&limit=${limit}&adult=${adult}`,
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Resolve a post URL into ALL of its media — every photo, every video, in order.
|
|
130
|
-
* Multi-item posts return one MediaObject per item; `truncated` is never silent.
|
|
131
|
-
* `engine` surfaces the provider attempts behind the result (multi-source transparency).
|
|
132
|
-
* @param {string} url
|
|
133
|
-
* @returns {Promise<{data: ResolveResult, engine?: {attempts: object[], finalProvider?: string|null, totalLatencyMs?: number|null}, meta: object}>}
|
|
134
|
-
*/
|
|
135
|
-
async resolve(url) {
|
|
136
|
-
const { data, meta } = await this._call("POST", "/api/v1/social/resolve", { url });
|
|
137
|
-
return { data: data?.result ?? data, engine: data?.engine, meta };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ---------- core: downloads ----------
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Enqueue a download job. Quality is honest: the result reports
|
|
144
|
-
* {requestedQuality, selectedQuality, fallback} — never manufactured.
|
|
145
|
-
* @param {{url: string, type?: "video"|"music"|"movie"|"short_video", quality?: string}} p
|
|
146
|
-
* quality: original | best | 2160p … 144p | audio
|
|
147
|
-
* @returns {Promise<{data: {jobId: string, status: string, progress: number, links: {status: string, file: string, cancel: string}}, meta: object}>}
|
|
148
25
|
*/
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
26
|
+
async search(p = {}) {
|
|
27
|
+
const t0 = Date.now();
|
|
28
|
+
const { items, attempts } = await registry.search({
|
|
29
|
+
q: p.q ?? p.query ?? "",
|
|
153
30
|
type: p.type ?? "video",
|
|
154
|
-
|
|
31
|
+
limit: Math.max(1, Math.min(25, p.limit ?? 12)),
|
|
32
|
+
adult: Boolean(p.adult),
|
|
155
33
|
});
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
34
|
+
return {
|
|
35
|
+
data: { items, adultFilter: { enabled: !p.adult } },
|
|
36
|
+
meta: { requestId: rid(), source: attempts.find((a) => a.status === "success")?.provider ?? null, timestamp: new Date().toISOString(), elapsedMs: Date.now() - t0 },
|
|
37
|
+
engine: { attempts },
|
|
38
|
+
};
|
|
161
39
|
}
|
|
162
40
|
|
|
163
41
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
42
|
+
* Resolve a post URL into ALL of its media — every photo, every video, in order.
|
|
43
|
+
* `engine.attempts` is the honest provider trail.
|
|
166
44
|
*/
|
|
167
|
-
|
|
168
|
-
return
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Cancel a queued/running job. */
|
|
172
|
-
cancelDownload(jobId) {
|
|
173
|
-
return this._call("POST", `/api/v1/download/${encodeURIComponent(jobId)}/cancel`);
|
|
45
|
+
async resolve(url) {
|
|
46
|
+
return registry.resolve(url);
|
|
174
47
|
}
|
|
175
48
|
|
|
176
49
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
50
|
+
* Download every media item to disk (order preserved, failures named).
|
|
51
|
+
* Quality is honest: `requestedQuality` vs `selectedQuality` + `fallback`.
|
|
52
|
+
* @param {{url: string, quality?: string, type?: string, dir?: string, zip?: boolean, onProgress?: (done:number,total:number)=>void}} p
|
|
179
53
|
*/
|
|
180
|
-
|
|
181
|
-
|
|
54
|
+
async download(p = {}) {
|
|
55
|
+
if (!p.url) throw new UMediaError(Codes.INVALID_REQUEST, "download({ url }) — url is required");
|
|
56
|
+
return download({ ...p, dir: p.dir || this.downloadDir });
|
|
182
57
|
}
|
|
183
58
|
|
|
184
|
-
/**
|
|
185
|
-
async
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
let res;
|
|
189
|
-
try {
|
|
190
|
-
res = await this._fetch(this.fileUrl(jobId, index), { headers });
|
|
191
|
-
} catch (e) {
|
|
192
|
-
throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `Network error: ${e.message}`);
|
|
193
|
-
}
|
|
194
|
-
if (!res.ok) {
|
|
195
|
-
let message = `HTTP ${res.status}`;
|
|
196
|
-
try {
|
|
197
|
-
const env = await res.json();
|
|
198
|
-
if (env?.error?.message) message = env.error.message;
|
|
199
|
-
} catch { /* binary error body */ }
|
|
200
|
-
throw new UMediaError(res.status === 404 ? Codes.NOT_FOUND : Codes.PROVIDER_ERROR, message, {
|
|
201
|
-
status: res.status,
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
return res;
|
|
59
|
+
/** Power tier: route a download through yt-dlp (1800+ sites, hard networks, muxing). */
|
|
60
|
+
async downloadWithYtdlp(p = {}) {
|
|
61
|
+
if (!p.url) throw new UMediaError(Codes.INVALID_REQUEST, "downloadWithYtdlp({ url }) — url is required");
|
|
62
|
+
return downloadWithYtdlp({ ...p, dir: p.dir || this.downloadDir });
|
|
205
63
|
}
|
|
206
64
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
capabilities() {
|
|
211
|
-
return this._call("GET", "/api/v1/capabilities");
|
|
65
|
+
/** What this engine can actually do on THIS machine — never overstated. */
|
|
66
|
+
async capabilities() {
|
|
67
|
+
return { data: registry.capabilities(), meta: { requestId: rid(), source: "local", timestamp: new Date().toISOString() } };
|
|
212
68
|
}
|
|
213
69
|
|
|
214
|
-
/** Real
|
|
215
|
-
status() {
|
|
216
|
-
return
|
|
70
|
+
/** Real engine status — which adapters are operational here. */
|
|
71
|
+
async status() {
|
|
72
|
+
return { data: registry.status(), meta: { requestId: rid(), source: "local", timestamp: new Date().toISOString() } };
|
|
217
73
|
}
|
|
218
74
|
}
|
|
219
75
|
|