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/index.d.ts CHANGED
@@ -1,6 +1,4 @@
1
- /** umedia — official client for the Unified Media API (core media engine). */
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-selection record — real attempts, never invented. */
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; // best real quality of this item
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 the max-media-items cap was hit — never silent truncation */
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 SearchResponse {
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
- size?: number;
122
- ext?: string;
103
+ path: string;
104
+ size: number;
105
+ ext: string;
123
106
  mimeType?: string | null;
124
107
  index: number;
125
- [k: string]: unknown;
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
- type?: string;
153
- status?: string; // queued | running | done | failed | cancelled
154
- stage?: string | null;
155
- progress?: number;
156
- requestedQuality?: string;
157
- createdAt?: number | null;
158
- startedAt?: number | null;
159
- finishedAt?: number | null;
160
- /** present when status === "done" */
161
- result?: DownloadResult | null;
162
- [k: string]: unknown;
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
- [k: string]: unknown;
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
- /** Optional API key — sends `X-API-Key` (higher rate limits). */
188
- apiKey?: string;
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
- apiKey: string | null;
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
- adult?: boolean;
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?: ResolveMeta; meta: Meta }>;
151
+ resolve(url: string): Promise<{ data: ResolveResult; engine: ResolveMeta }>;
209
152
 
210
- createDownload(p: {
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
- }): Promise<Envelope<DownloadHandle>>;
216
-
217
- listDownloads(): Promise<Envelope<{ jobs?: DownloadJob[]; [k: string]: unknown }>>;
218
-
219
- getDownload(jobId: string): Promise<Envelope<DownloadJob>>;
220
-
221
- cancelDownload(jobId: string): Promise<Envelope<DownloadJob>>;
222
-
223
- fileUrl(jobId: string, index?: number): string;
224
-
225
- fetchFile(jobId: string, index?: number): Promise<Response>;
226
-
227
- capabilities(): Promise<Envelope<{ platforms?: unknown[]; [k: string]: unknown }>>;
228
-
229
- status(): Promise<Envelope<{ overall?: string; [k: string]: unknown }>>;
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 — official client for the Unified Media API (core media engine).
3
- * Search · Resolve · Download — honest quality labels, real previews.
4
- * Zero dependencies. Node 18+ / any fetch-capable runtime.
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 const DEFAULT_BASE_URL = "https://pappy-api-downloader.duckdns.org";
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
- const { apiKey, baseUrl = DEFAULT_BASE_URL, timeoutMs = 60_000, fetch: fetchImpl } = opts;
52
- this.apiKey = apiKey || null;
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
- * Search across providers (music leads with real 30s previews).
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
- createDownload(p = {}) {
150
- if (!p.url) throw new UMediaError(Codes.INVALID_REQUEST, "createDownload: { url } is required");
151
- return this._call("POST", "/api/v1/download", {
152
- url: p.url,
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
- quality: p.quality ?? "best",
31
+ limit: Math.max(1, Math.min(25, p.limit ?? 12)),
32
+ adult: Boolean(p.adult),
155
33
  });
156
- }
157
-
158
- /** List download jobs for this API key. */
159
- listDownloads() {
160
- return this._call("GET", "/api/v1/download");
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
- * Poll a download job. When done, `data.result` carries files[], itemCount,
165
- * requestedItems, truncated, failedItems[], requestedQuality, selectedQuality.
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
- getDownload(jobId) {
168
- return this._call("GET", `/api/v1/download/${encodeURIComponent(jobId)}`);
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
- * Direct URL of one result file. Single-file jobs stream the file;
178
- * multi-item jobs serve an ordered zip (`001 - name`).
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
- fileUrl(jobId, index = 0) {
181
- return `${this.baseUrl}/api/v1/download/${encodeURIComponent(jobId)}/file?index=${index}`;
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
- /** Fetch one result file as a Response (streams in Node and browsers). */
185
- async fetchFile(jobId, index = 0) {
186
- const headers = {};
187
- if (this.apiKey) headers["x-api-key"] = this.apiKey;
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
- // ---------- platform metadata (public) ----------
208
-
209
- /** Live capability registry: which platforms/media types the engine serves. */
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 operational status — components and provider intelligence. */
215
- status() {
216
- return this._call("GET", "/api/v1/status");
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