umedia 0.3.0 → 0.4.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/lib/registry.js CHANGED
@@ -10,13 +10,17 @@ import * as bluesky from "./providers/bluesky.js";
10
10
  import * as reddit from "./providers/reddit.js";
11
11
  import * as xprov from "./providers/x.js";
12
12
  import * as ig from "./providers/instagram.js";
13
- import * as ogembed from "./providers/ogembed.js";
13
+ import * as facebook from "./providers/facebook.js";
14
+ import * as threads from "./providers/threads.js";
15
+ import * as snapchat from "./providers/snapchat.js";
16
+ import * as rumble from "./providers/rumble.js";
17
+ import * as tumblr from "./providers/tumblr.js";
14
18
  import * as ytdlp from "./providers/ytdlp.js";
15
19
  import * as itunes from "./providers/itunes.js";
16
20
  import { Codes, UMediaError } from "./errors.js";
17
21
  import { validateUrl } from "./util.js";
18
22
 
19
- const NATIVE = [yt, tiktok, pinterest, soundcloud, dailymotion, vimeo, streamable, twitch, bluesky, reddit, xprov, ig, ogembed];
23
+ const NATIVE = [yt, tiktok, pinterest, soundcloud, dailymotion, vimeo, streamable, twitch, bluesky, reddit, xprov, ig, facebook, threads, snapchat, rumble, tumblr];
20
24
 
21
25
  export function detectPlatform(url) {
22
26
  for (const p of NATIVE) {
@@ -57,6 +61,7 @@ export async function resolve(url) {
57
61
  }
58
62
 
59
63
  const ctx = { attempts: [] };
64
+ let lastTyped = null;
60
65
  for (const p of NATIVE) {
61
66
  if (!p.canHandle(url)) continue;
62
67
  try {
@@ -69,6 +74,7 @@ export async function resolve(url) {
69
74
  throw e;
70
75
  }
71
76
  // transient/provider failure → try the next layer
77
+ if (e instanceof UMediaError) lastTyped = e;
72
78
  continue;
73
79
  }
74
80
  }
@@ -82,6 +88,12 @@ export async function resolve(url) {
82
88
  }
83
89
  }
84
90
  const last = ctx.attempts[ctx.attempts.length - 1];
91
+ // surface the real provider error when every matching provider failed —
92
+ // never mask a typed outcome behind a generic message
93
+ if (lastTyped) {
94
+ lastTyped.attempts = ctx.attempts;
95
+ throw lastTyped;
96
+ }
85
97
  throw new UMediaError(
86
98
  detectPlatform(url) === "unknown" ? Codes.INVALID_URL : Codes.PROVIDER_UNAVAILABLE,
87
99
  `No provider could resolve this URL.${last?.error ? " Last error: " + last.error : ""}` +
@@ -115,10 +127,14 @@ export function capabilities() {
115
127
  { platform: "streamable", search: false, resolve: true, download: true, notes: "direct mp4 renditions" },
116
128
  { platform: "twitch", search: false, resolve: true, download: true, notes: "clips = direct mp4s; VODs/livestreams via yt-dlp tier" },
117
129
  { platform: "bluesky", search: false, resolve: true, download: true, notes: "public AppView API: full-size images + video playlists" },
118
- { platform: "instagram", search: false, resolve: true, download: true, notes: "public embed best-effort" },
119
- { platform: "reddit", search: false, resolve: true, download: true, notes: "posts + galleries" },
120
- { platform: "x", search: false, resolve: true, download: true, notes: "public posts via syndication" },
121
- { platform: "facebook · threads · snapchat · rumble · tumblr", search: false, resolve: "best-effort", download: "best-effort", notes: "official Open Graph media; often login-gated on datacenter IPs — yt-dlp tier reliable" },
130
+ { platform: "instagram", search: false, resolve: true, download: true, notes: "oEmbed→mobile info API: photos, videos, full carousels" },
131
+ { platform: "reddit", search: false, resolve: true, download: true, notes: "posts + galleries + video with audio sidecar" },
132
+ { platform: "x", search: false, resolve: true, download: true, notes: "syndication token + guest GraphQL — photos/videos/gifs" },
133
+ { platform: "facebook", search: false, resolve: true, download: true, notes: "reels/watch/videos/shares/fb.watch via the player JSON" },
134
+ { platform: "threads", search: false, resolve: true, download: true, notes: "post SSR state + og media" },
135
+ { platform: "snapchat", search: false, resolve: true, download: true, notes: "spotlight videos + public story snap lists" },
136
+ { platform: "rumble", search: false, resolve: true, download: true, notes: "embed API: direct mp4 renditions" },
137
+ { platform: "tumblr", search: false, resolve: true, download: true, notes: "mobile API: video/audio/photo posts + reblog trails" },
122
138
  { platform: "itunes", search: true, resolve: false, download: "previews", notes: "real 30s clips" },
123
139
  { platform: "ytdlp", search: false, resolve: Boolean(ytdlp.binary()), download: Boolean(ytdlp.binary()), notes: "optional tier: 1800+ sites — install yt-dlp" },
124
140
  ],
@@ -140,10 +156,14 @@ export function status() {
140
156
  { name: "streamable", state: "operational", via: "public video api" },
141
157
  { name: "twitch", state: "operational", via: "public GraphQL (clips)" },
142
158
  { name: "bluesky", state: "operational", via: "public AppView XRPC" },
143
- { name: "instagram", state: "degraded", via: "public embed (best-effort)" },
159
+ { name: "instagram", state: "operational", via: "oEmbed + mobile info api" },
144
160
  { name: "reddit", state: "operational", via: "public json" },
145
- { name: "x", state: "degraded", via: "syndication (best-effort)" },
146
- { name: "ogembed", state: "degraded", via: "open graph meta (best-effort)" },
161
+ { name: "x", state: "operational", via: "syndication + guest graphql" },
162
+ { name: "facebook", state: "operational", via: "web player json" },
163
+ { name: "threads", state: "operational", via: "share page state" },
164
+ { name: "snapchat", state: "operational", via: "spotlight + story state" },
165
+ { name: "rumble", state: "operational", via: "embed json api" },
166
+ { name: "tumblr", state: "operational", via: "mobile api" },
147
167
  { name: "itunes", state: "operational", via: "search api" },
148
168
  { name: "ytdlp", state: ytdlp.binary() ? "operational" : "unavailable", via: "external binary" },
149
169
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "umedia",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Standalone media engine — search, resolve and download media from YouTube, TikTok, Instagram, Reddit, X, iTunes (and 1800+ sites via the yt-dlp tier) entirely on your machine. No hosted API, no keys, no server bills. Honest quality labels, complete galleries, typed errors.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -1,111 +0,0 @@
1
- import { fetchText, hostMatches } from "../util.js";
2
- import { Codes, UMediaError } from "../errors.js";
3
-
4
- /**
5
- * og-embed tier — Facebook, Threads, Snapchat Spotlight, Rumble, Tumblr.
6
- * These platforms publish official media previews as Open Graph meta tags on
7
- * their public share pages; some networks gate them (typed honest errors there,
8
- * the yt-dlp tier is the reliable path). No scraping tricks — their own tags.
9
- */
10
- export const name = "ogembed";
11
-
12
- const HOSTS = ["facebook.com", "fb.watch", "threads.net", "threads.com", "snapchat.com", "rumble.com", "tumblr.com"];
13
-
14
- export function canHandle(url) {
15
- try {
16
- return hostMatches(new URL(String(url)).hostname, HOSTS);
17
- } catch { return false; }
18
- }
19
-
20
- function meta(h, key) {
21
- const pats = [
22
- new RegExp(`<meta[^>]+(?:property|name)="${key}"[^>]+content="([^"]*)"`, "i"),
23
- new RegExp(`<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="${key}"`, "i"),
24
- ];
25
- for (const p of pats) {
26
- const m = h.match(p);
27
- if (m) return m[1].replace(/&amp;/g, "&").replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n));
28
- }
29
- return null;
30
- }
31
-
32
- export async function resolve(url, ctx = {}) {
33
- const attempt = { provider: name, status: "skipped" };
34
- ctx.attempts?.push(attempt);
35
- const t0 = Date.now();
36
- const host = (() => { try { return new URL(String(url)).hostname.replace(/^www\./, ""); } catch { return "?"; } })();
37
-
38
- let html;
39
- try {
40
- const r = await fetchText(String(url), {}, 18_000);
41
- if (r.status >= 400) {
42
- attempt.status = "failed";
43
- attempt.error = `HTTP ${r.status}`;
44
- throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `${host} returned HTTP ${r.status}`, { attempts: [attempt] });
45
- }
46
- html = r.text;
47
- } catch (e) {
48
- if (e instanceof UMediaError) throw e;
49
- attempt.status = "failed";
50
- attempt.error = e.message;
51
- throw new UMediaError(Codes.PROVIDER_UNAVAILABLE, `${host} not retrievable. ${e.message}`, { attempts: [attempt] });
52
- }
53
-
54
- const video = meta(html, "og:video:secure_url") || meta(html, "og:video") || meta(html, "og:video:url");
55
- const image = meta(html, "og:image:secure_url") || meta(html, "og:image");
56
- const title = meta(html, "og:title");
57
- const desc = meta(html, "og:description");
58
- const author = meta(html, "og:article:author") || meta(html, "twitter:creator") || null;
59
-
60
- const media = [];
61
- if (video && /\.mp4|\.m3u8|video/i.test(video)) {
62
- media.push({
63
- type: "video", index: 0, url: video, thumbnail: image,
64
- mimeType: /\.m3u8/i.test(video) ? "application/x-mpegURL" : "video/mp4",
65
- width: parseInt(meta(html, "og:video:width") || "", 10) || null,
66
- height: parseInt(meta(html, "og:video:height") || "", 10) || null,
67
- duration: parseInt(meta(html, "video:duration") || meta(html, "og:video:duration") || "", 10) || null,
68
- size: null, quality: null,
69
- hasAudio: true, hasVideo: true, source: name, variants: [],
70
- });
71
- }
72
- if (image && (!video || !/\.mp4/i.test(video))) {
73
- media.push({
74
- type: "image", index: media.length, url: image, thumbnail: image,
75
- mimeType: "image/jpeg", width: null, height: null, duration: null, size: null,
76
- quality: null, hasAudio: false, hasVideo: false, source: name, variants: [],
77
- });
78
- }
79
-
80
- if (!media.length) {
81
- attempt.status = "failed";
82
- attempt.error = "no media in the public share page (login-gated or removed)";
83
- throw new UMediaError(Codes.MEDIA_NOT_FOUND,
84
- `${host} exposes no media on its public page (often login-gated from datacenter IPs) — the yt-dlp tier is the reliable path. ${attempt.error}`, { attempts: [attempt] });
85
- }
86
-
87
- attempt.status = "success";
88
- attempt.latencyMs = Date.now() - t0;
89
- return {
90
- result: {
91
- sourceUrl: url,
92
- platform: host,
93
- title: title || desc || null,
94
- author,
95
- thumbnail: image || media[0].thumbnail,
96
- itemCount: media.length,
97
- counts: {
98
- images: media.filter((m) => m.type === "image").length,
99
- videos: media.filter((m) => m.type === "video").length,
100
- audios: 0, other: 0,
101
- },
102
- truncated: false,
103
- media,
104
- },
105
- engine: { attempts: ctx.attempts || [attempt], finalProvider: name, totalLatencyMs: Date.now() - t0 },
106
- };
107
- }
108
-
109
- export async function search() {
110
- return [];
111
- }