youtube-thumbnail-url 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ThumbnailsGrabber (thumbnailsgrabber.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # youtube-thumbnail-url
2
+
3
+ Build every YouTube thumbnail URL for a video and resolve the largest one that
4
+ **really exists**. Zero dependencies, ~6 KB, works in Node 18+ and in the browser.
5
+
6
+ Most snippets on the web do `https://img.youtube.com/vi/ID/maxresdefault.jpg` and
7
+ hope. About 1 in 9 videos has no `maxresdefault`, and when it is missing YouTube
8
+ answers **HTTP 404 with a 120x90 grey JPEG as the body**, so an `<img>` tag still
9
+ "loads" and shows a tiny grey box. This package checks the status *and* the real
10
+ pixel width (from the first 4 KB of the file) before it hands you a URL.
11
+
12
+ The fallback order and the notes on each size are based on a measurement of
13
+ 8,664 public videos, published with a CC BY 4.0 dataset:
14
+ [YouTube Thumbnail Statistics](https://thumbnailsgrabber.com/youtube-thumbnail-statistics).
15
+ The URL patterns themselves are documented in the
16
+ [YouTube Thumbnail URL Guide](https://thumbnailsgrabber.com/youtube-thumbnail-url-guide).
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ npm install youtube-thumbnail-url
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```js
27
+ import { thumbnailUrl, thumbnailUrls, resolveThumbnail, getVideoId } from 'youtube-thumbnail-url';
28
+ // CommonJS: const { thumbnailUrl } = require('youtube-thumbnail-url');
29
+
30
+ getVideoId('https://youtu.be/dQw4w9WgXcQ?si=x'); // 'dQw4w9WgXcQ'
31
+ getVideoId('https://www.youtube.com/shorts/tPEE9ZwTmy0'); // 'tPEE9ZwTmy0'
32
+
33
+ thumbnailUrl('dQw4w9WgXcQ');
34
+ // 'https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg'
35
+
36
+ thumbnailUrl('dQw4w9WgXcQ', 'hqdefault', { format: 'webp' });
37
+ // 'https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hqdefault.webp' (about half the bytes)
38
+
39
+ thumbnailUrls('dQw4w9WgXcQ');
40
+ // { default: { url, width: 120, height: 90 }, mqdefault: {...}, hqdefault: {...},
41
+ // sddefault: {...}, hq720: {...}, maxresdefault: {...}, oar2: {...} }
42
+
43
+ // The part that matters: the biggest file that actually exists.
44
+ const best = await resolveThumbnail('https://www.youtube.com/watch?v=tPEE9ZwTmy0');
45
+ // { id: 'tPEE9ZwTmy0', size: 'sddefault', format: 'jpg',
46
+ // url: 'https://i.ytimg.com/vi/tPEE9ZwTmy0/sddefault.jpg',
47
+ // width: 640, height: 480, bytes: 55898, status: 206,
48
+ // checked: [ { size: 'maxresdefault', ok: false, status: 404, ... }, { size: 'sddefault', ok: true, ... } ] }
49
+ ```
50
+
51
+ Prefer the vertical Shorts cover when there is one, WebP when available:
52
+
53
+ ```js
54
+ await resolveThumbnail(id, { order: ['oar2', 'maxresdefault', 'sddefault', 'hqdefault'], format: 'webp' });
55
+ ```
56
+
57
+ Check a single URL:
58
+
59
+ ```js
60
+ import { checkThumbnail } from 'youtube-thumbnail-url';
61
+ await checkThumbnail('https://i.ytimg.com/vi/tPEE9ZwTmy0/maxresdefault.jpg');
62
+ // { ok: false, status: 404, width: null, height: null, bytes: 1097 }
63
+ ```
64
+
65
+ ## Sizes
66
+
67
+ | name | pixels | exists for |
68
+ | --------------- | ------------- | ----------------------------------- |
69
+ | `default` | 120 x 90 | 100% of videos |
70
+ | `mqdefault` | 320 x 180 | 100% |
71
+ | `hqdefault` | 480 x 360 | 100% (4:3, black bars) |
72
+ | `sddefault` | 640 x 480 | ~96% (4:3, black bars) |
73
+ | `hq720` | 1280 x 720 | same file as `maxresdefault` |
74
+ | `maxresdefault` | 1280 x 720 | ~88% (0% of 2008-09 uploads, 91-95% since 2023) |
75
+ | `oar2` | 1080 x 1920 | ~30%, the vertical Shorts cover |
76
+
77
+ `maxresdefault -> sddefault -> hqdefault` covers 100% of videos, which is why it is
78
+ the default `order`. There is no 4K or 1080p landscape rendition; 1280 x 720 is the
79
+ ceiling for practically every video. Percentages: [study, Sept 2026](https://thumbnailsgrabber.com/youtube-thumbnail-statistics).
80
+
81
+ Frame captures are available too: `frameUrl(id, 1, 'hq')` gives `hq1.jpg`
82
+ (frames 1-3 are taken at roughly 25/50/75% of the video; `0.jpg` is the default frame).
83
+
84
+ ## API
85
+
86
+ - `getVideoId(input) -> string | null`
87
+ - `thumbnailUrl(idOrUrl, size = 'maxresdefault', { host, format })`
88
+ - `thumbnailUrls(idOrUrl, { host, format })`
89
+ - `frameUrl(idOrUrl, frame 0-3, quality '' | 'mq' | 'hq' | 'sd' | 'maxres', { host, format })`
90
+ - `checkThumbnail(url, { fetch, signal })` -> `{ url, ok, status, width, height, bytes }`
91
+ - `resolveThumbnail(idOrUrl, { order, format, host, fetch, signal })`
92
+ - `readDimensions(uint8array)` -> `{ width, height } | null` (JPEG SOF and WebP VP8/VP8L/VP8X)
93
+ - `SIZES`, `FALLBACK_ORDER`, `HOSTS`
94
+
95
+ `host` accepts `i.ytimg.com` (default), `i1`-`i4.ytimg.com` and `img.youtube.com`;
96
+ they all serve the same files. Pass your own `fetch` (for example `undici` or a
97
+ proxy) if the global one does not suit you.
98
+
99
+ ## Browser
100
+
101
+ `checkThumbnail` and `resolveThumbnail` send a `Range` request with `fetch`. The
102
+ ytimg servers send `Access-Control-Allow-Origin: *`, so this works from a page as
103
+ well as from Node. If you only need a URL and are happy to detect the grey box
104
+ yourself, `new Image()` with a `naturalWidth > 121` check does the same job.
105
+
106
+ ## Tests
107
+
108
+ ```sh
109
+ npm test # offline unit tests
110
+ LIVE=1 npm test # also hits i.ytimg.com for three real videos
111
+ ```
112
+
113
+ ## Related
114
+
115
+ - Online tool: [ThumbnailsGrabber - YouTube thumbnail grabber](https://thumbnailsgrabber.com/)
116
+ - [YouTube Thumbnail URL Guide](https://thumbnailsgrabber.com/youtube-thumbnail-url-guide)
117
+ - [YouTube Thumbnail Statistics (dataset)](https://thumbnailsgrabber.com/youtube-thumbnail-statistics)
118
+ - [Get YouTube thumbnails in Python](https://thumbnailsgrabber.com/youtube-thumbnail-python)
119
+ - [Get YouTube thumbnails from the command line](https://thumbnailsgrabber.com/youtube-thumbnail-command-line)
120
+
121
+ ## License
122
+
123
+ MIT
package/index.cjs ADDED
@@ -0,0 +1,255 @@
1
+ 'use strict';
2
+ /*!
3
+ * youtube-thumbnail-url
4
+ * Build every YouTube thumbnail URL for a video, and resolve the best one that
5
+ * really exists (maxresdefault -> sddefault -> hqdefault), checking the HTTP
6
+ * status and the real pixel width instead of trusting the URL.
7
+ *
8
+ * Zero dependencies. Works in Node 18+ and in browsers (uses global fetch).
9
+ * Maintained by ThumbnailsGrabber - https://thumbnailsgrabber.com
10
+ * Reference: https://thumbnailsgrabber.com/youtube-thumbnail-url-guide
11
+ * Data: https://thumbnailsgrabber.com/youtube-thumbnail-statistics
12
+ * License: MIT
13
+ */
14
+
15
+ var ID_RE = /^[0-9A-Za-z_-]{11}$/;
16
+
17
+ /**
18
+ * Every named rendition YouTube serves under /vi/<id>/, with the pixel size
19
+ * it has when it exists. Frame captures (1.jpg, hq2.jpg ...) are covered by
20
+ * frameUrl(). Availability figures come from a 2026 measurement of 8,664
21
+ * public videos: https://thumbnailsgrabber.com/youtube-thumbnail-statistics
22
+ */
23
+ var SIZES = {
24
+ default: { width: 120, height: 90, note: 'always exists' },
25
+ mqdefault: { width: 320, height: 180, note: 'always exists' },
26
+ hqdefault: { width: 480, height: 360, note: 'always exists (letterboxed 4:3)' },
27
+ sddefault: { width: 640, height: 480, note: 'about 96% of videos (letterboxed 4:3)' },
28
+ hq720: { width: 1280, height: 720, note: 'same file as maxresdefault when it exists' },
29
+ maxresdefault: { width: 1280, height: 720, note: 'about 88% of videos; missing = HTTP 404 with a 120x90 grey body' },
30
+ oar2: { width: 1080, height: 1920, note: 'vertical Shorts cover, about 30% of videos (some are 720x1280)' }
31
+ };
32
+
33
+ /** Landscape ladder, largest first. maxres -> sd -> hq covers 100% of videos. */
34
+ var FALLBACK_ORDER = ['maxresdefault', 'sddefault', 'hqdefault'];
35
+
36
+ var HOSTS = ['i.ytimg.com', 'i1.ytimg.com', 'i2.ytimg.com', 'i3.ytimg.com', 'i4.ytimg.com', 'img.youtube.com'];
37
+
38
+ /**
39
+ * Extract the 11-character video ID from a bare ID or any YouTube URL
40
+ * (watch?v=, youtu.be/, /shorts/, /embed/, /live/, /v/, music., m., nocookie).
41
+ * Returns null when nothing that looks like an ID is found.
42
+ */
43
+ function getVideoId(input) {
44
+ var raw = String(input == null ? '' : input).trim();
45
+ if (!raw) return null;
46
+ if (ID_RE.test(raw)) return raw;
47
+ if (!/^https?:\/\//i.test(raw)) raw = 'https://' + raw;
48
+ try {
49
+ var u = new URL(raw);
50
+ var v = u.searchParams.get('v');
51
+ if (v && ID_RE.test(v)) return v;
52
+ var parts = u.pathname.split('/').filter(Boolean);
53
+ for (var i = 0; i < parts.length; i++) {
54
+ var p = parts[i];
55
+ if ((p === 'shorts' || p === 'embed' || p === 'live' || p === 'v' || p === 'vi' || p === 'vi_webp') && parts[i + 1]) {
56
+ var c = parts[i + 1].substring(0, 11);
57
+ if (ID_RE.test(c)) return c;
58
+ }
59
+ }
60
+ var last = (parts[parts.length - 1] || '').substring(0, 11);
61
+ if (ID_RE.test(last)) return last;
62
+ } catch (e) { /* fall through */ }
63
+ var m = String(input).match(/(?:v=|\/)([0-9A-Za-z_-]{11})(?:[?&#\/]|$)/);
64
+ return m ? m[1] : null;
65
+ }
66
+
67
+ function normalizeOptions(opts) {
68
+ opts = opts || {};
69
+ var host = opts.host || 'i.ytimg.com';
70
+ if (HOSTS.indexOf(host) === -1) throw new Error('Unknown ytimg host: ' + host);
71
+ var format = opts.format === 'webp' ? 'webp' : 'jpg';
72
+ return { host: host, format: format };
73
+ }
74
+
75
+ /**
76
+ * URL of one named rendition. size defaults to 'maxresdefault'.
77
+ * opts.format = 'jpg' (default) | 'webp' (served from /vi_webp/, ~half the bytes).
78
+ * opts.host = 'i.ytimg.com' (default) | i1-i4.ytimg.com | 'img.youtube.com'.
79
+ */
80
+ function thumbnailUrl(idOrUrl, size, opts) {
81
+ var id = getVideoId(idOrUrl);
82
+ if (!id) throw new Error('Could not find a YouTube video ID in: ' + idOrUrl);
83
+ size = size || 'maxresdefault';
84
+ if (!SIZES[size]) throw new Error('Unknown thumbnail size: ' + size + ' (expected one of ' + Object.keys(SIZES).join(', ') + ')');
85
+ var o = normalizeOptions(opts);
86
+ var dir = o.format === 'webp' ? 'vi_webp' : 'vi';
87
+ return 'https://' + o.host + '/' + dir + '/' + id + '/' + size + '.' + o.format;
88
+ }
89
+
90
+ /**
91
+ * URL of an auto-captured frame. frame 0 = the default thumbnail frame, 1-3 =
92
+ * frames at roughly 25/50/75% of the video. quality '' (120x90) | 'mq' | 'hq' | 'sd' | 'maxres'.
93
+ */
94
+ function frameUrl(idOrUrl, frame, quality, opts) {
95
+ var id = getVideoId(idOrUrl);
96
+ if (!id) throw new Error('Could not find a YouTube video ID in: ' + idOrUrl);
97
+ frame = Number(frame);
98
+ if (!(frame >= 0 && frame <= 3 && frame === Math.floor(frame))) throw new Error('frame must be 0, 1, 2 or 3');
99
+ quality = quality || '';
100
+ if (['', 'mq', 'hq', 'sd', 'maxres'].indexOf(quality) === -1) throw new Error('quality must be "", "mq", "hq", "sd" or "maxres"');
101
+ var o = normalizeOptions(opts);
102
+ var dir = o.format === 'webp' ? 'vi_webp' : 'vi';
103
+ return 'https://' + o.host + '/' + dir + '/' + id + '/' + quality + frame + '.' + o.format;
104
+ }
105
+
106
+ /**
107
+ * All named renditions at once: { maxresdefault: { url, width, height }, ... }.
108
+ * Widths/heights are the sizes the file has WHEN IT EXISTS - use resolveThumbnail()
109
+ * to find out which ones actually do.
110
+ */
111
+ function thumbnailUrls(idOrUrl, opts) {
112
+ var out = {};
113
+ Object.keys(SIZES).forEach(function (size) {
114
+ out[size] = {
115
+ url: thumbnailUrl(idOrUrl, size, opts),
116
+ width: SIZES[size].width,
117
+ height: SIZES[size].height
118
+ };
119
+ });
120
+ return out;
121
+ }
122
+
123
+ /* ---------- image header parsing (first ~4 KB is enough) ---------- */
124
+
125
+ function readDimensions(bytes) {
126
+ if (!bytes || bytes.length < 16) return null;
127
+ // JPEG: scan markers for a SOF segment.
128
+ if (bytes[0] === 0xFF && bytes[1] === 0xD8) {
129
+ var i = 2;
130
+ while (i + 9 < bytes.length) {
131
+ if (bytes[i] !== 0xFF) { i++; continue; }
132
+ var marker = bytes[i + 1];
133
+ if (marker === 0xFF) { i++; continue; }
134
+ if (marker === 0xD8 || (marker >= 0xD0 && marker <= 0xD7) || marker === 0x01) { i += 2; continue; }
135
+ var len = (bytes[i + 2] << 8) | bytes[i + 3];
136
+ var isSOF = (marker >= 0xC0 && marker <= 0xCF) && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC;
137
+ if (isSOF) {
138
+ return { height: (bytes[i + 5] << 8) | bytes[i + 6], width: (bytes[i + 7] << 8) | bytes[i + 8] };
139
+ }
140
+ if (marker === 0xDA) break; // start of scan, no SOF seen
141
+ i += 2 + len;
142
+ }
143
+ return null;
144
+ }
145
+ // WebP: RIFF....WEBP + VP8 / VP8L / VP8X chunk.
146
+ if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
147
+ bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 && bytes.length >= 25) {
148
+ var chunk = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
149
+ if (chunk === 'VP8 ') {
150
+ return { width: ((bytes[27] << 8) | bytes[26]) & 0x3FFF, height: ((bytes[29] << 8) | bytes[28]) & 0x3FFF };
151
+ }
152
+ if (chunk === 'VP8L') {
153
+ var b0 = bytes[21], b1 = bytes[22], b2 = bytes[23], b3 = bytes[24];
154
+ return { width: 1 + (((b1 & 0x3F) << 8) | b0), height: 1 + (((b3 & 0x0F) << 10) | (b2 << 2) | ((b1 & 0xC0) >> 6)) };
155
+ }
156
+ if (chunk === 'VP8X' && bytes.length >= 30) {
157
+ return { width: 1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)), height: 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16)) };
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+
163
+ function parseTotalBytes(res) {
164
+ var cr = res.headers && res.headers.get && res.headers.get('content-range');
165
+ if (cr) { var m = /\/(\d+)\s*$/.exec(cr); if (m) return Number(m[1]); }
166
+ var cl = res.headers && res.headers.get && res.headers.get('content-length');
167
+ return cl ? Number(cl) : null;
168
+ }
169
+
170
+ /**
171
+ * Check whether one thumbnail URL is a real image.
172
+ * Downloads only the first 4 KB (Range request) and parses the JPEG/WebP header.
173
+ * Resolves { url, ok, status, width, height, bytes }.
174
+ * ok = HTTP 2xx AND (width unknown OR width > 121).
175
+ * A missing maxresdefault/sddefault/oar2 is an HTTP 404 whose body is still a
176
+ * 120x90 grey JPEG, so <img> tags render a tiny grey box; both checks catch it.
177
+ */
178
+ function checkThumbnail(url, opts) {
179
+ opts = opts || {};
180
+ var f = opts.fetch || (typeof fetch === 'function' ? fetch : null);
181
+ if (!f) return Promise.reject(new Error('No fetch available; pass opts.fetch'));
182
+ var init = { method: 'GET', headers: { Range: 'bytes=0-4095' }, redirect: 'follow' };
183
+ if (opts.signal) init.signal = opts.signal;
184
+ return f(url, init).then(function (res) {
185
+ var status = res.status;
186
+ var bytes = parseTotalBytes(res);
187
+ if (status < 200 || status > 299) {
188
+ return res.arrayBuffer().then(function () { return null; }, function () { return null; }).then(function () {
189
+ return { url: url, ok: false, status: status, width: null, height: null, bytes: bytes };
190
+ });
191
+ }
192
+ return res.arrayBuffer().then(function (buf) {
193
+ var dims = readDimensions(new Uint8Array(buf));
194
+ if (status === 200 && bytes == null) bytes = buf.byteLength;
195
+ var width = dims ? dims.width : null;
196
+ var height = dims ? dims.height : null;
197
+ var ok = width == null ? true : width > 121;
198
+ return { url: url, ok: ok, status: status, width: width, height: height, bytes: bytes };
199
+ });
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Find the largest thumbnail that really exists.
205
+ * opts.order = ['maxresdefault','sddefault','hqdefault'] (default). Add 'oar2'
206
+ * first to prefer the vertical Shorts cover when there is one.
207
+ * opts.format = 'jpg' | 'webp' (webp falls back to jpg when webp is missing)
208
+ * opts.host, opts.fetch, opts.signal as in checkThumbnail.
209
+ * Resolves { id, size, url, width, height, bytes, status, checked: [...] }.
210
+ * Never rejects for a missing rendition; rejects only when no size responded 2xx.
211
+ */
212
+ function resolveThumbnail(idOrUrl, opts) {
213
+ opts = opts || {};
214
+ var id = getVideoId(idOrUrl);
215
+ if (!id) return Promise.reject(new Error('Could not find a YouTube video ID in: ' + idOrUrl));
216
+ var order = (opts.order && opts.order.length) ? opts.order.slice() : FALLBACK_ORDER.slice();
217
+ var formats = opts.format === 'webp' ? ['webp', 'jpg'] : ['jpg'];
218
+ var checked = [];
219
+ var attempts = [];
220
+ order.forEach(function (size) {
221
+ formats.forEach(function (fmt) { attempts.push({ size: size, format: fmt }); });
222
+ });
223
+ var i = 0;
224
+ function next() {
225
+ if (i >= attempts.length) {
226
+ var err = new Error('No thumbnail responded 2xx for video ' + id);
227
+ err.checked = checked;
228
+ return Promise.reject(err);
229
+ }
230
+ var a = attempts[i++];
231
+ var url = thumbnailUrl(id, a.size, { host: opts.host, format: a.format });
232
+ return checkThumbnail(url, opts).then(function (r) {
233
+ r.size = a.size; r.format = a.format;
234
+ checked.push(r);
235
+ if (r.ok) {
236
+ return { id: id, size: a.size, format: a.format, url: r.url, width: r.width, height: r.height, bytes: r.bytes, status: r.status, checked: checked };
237
+ }
238
+ return next();
239
+ });
240
+ }
241
+ return next();
242
+ }
243
+
244
+ module.exports = {
245
+ SIZES: SIZES,
246
+ FALLBACK_ORDER: FALLBACK_ORDER,
247
+ HOSTS: HOSTS,
248
+ getVideoId: getVideoId,
249
+ thumbnailUrl: thumbnailUrl,
250
+ thumbnailUrls: thumbnailUrls,
251
+ frameUrl: frameUrl,
252
+ checkThumbnail: checkThumbnail,
253
+ resolveThumbnail: resolveThumbnail,
254
+ readDimensions: readDimensions
255
+ };
package/index.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ export type ThumbnailSize =
2
+ | 'default' | 'mqdefault' | 'hqdefault' | 'sddefault' | 'hq720' | 'maxresdefault' | 'oar2';
3
+ export type ThumbnailFormat = 'jpg' | 'webp';
4
+ export type YtimgHost =
5
+ | 'i.ytimg.com' | 'i1.ytimg.com' | 'i2.ytimg.com' | 'i3.ytimg.com' | 'i4.ytimg.com' | 'img.youtube.com';
6
+
7
+ export interface UrlOptions { host?: YtimgHost; format?: ThumbnailFormat; }
8
+ export interface FetchOptions extends UrlOptions {
9
+ fetch?: typeof fetch;
10
+ signal?: AbortSignal;
11
+ }
12
+ export interface ResolveOptions extends FetchOptions { order?: ThumbnailSize[]; }
13
+
14
+ export interface SizeInfo { width: number; height: number; note: string; }
15
+ export interface UrlInfo { url: string; width: number; height: number; }
16
+ export interface CheckResult {
17
+ url: string; ok: boolean; status: number;
18
+ width: number | null; height: number | null; bytes: number | null;
19
+ size?: ThumbnailSize; format?: ThumbnailFormat;
20
+ }
21
+ export interface ResolveResult {
22
+ id: string; size: ThumbnailSize; format: ThumbnailFormat; url: string;
23
+ width: number | null; height: number | null; bytes: number | null; status: number;
24
+ checked: CheckResult[];
25
+ }
26
+
27
+ export const SIZES: Record<ThumbnailSize, SizeInfo>;
28
+ export const FALLBACK_ORDER: ThumbnailSize[];
29
+ export const HOSTS: YtimgHost[];
30
+ export function getVideoId(input: string): string | null;
31
+ export function thumbnailUrl(idOrUrl: string, size?: ThumbnailSize, opts?: UrlOptions): string;
32
+ export function thumbnailUrls(idOrUrl: string, opts?: UrlOptions): Record<ThumbnailSize, UrlInfo>;
33
+ export function frameUrl(idOrUrl: string, frame: 0 | 1 | 2 | 3, quality?: '' | 'mq' | 'hq' | 'sd' | 'maxres', opts?: UrlOptions): string;
34
+ export function checkThumbnail(url: string, opts?: FetchOptions): Promise<CheckResult>;
35
+ export function resolveThumbnail(idOrUrl: string, opts?: ResolveOptions): Promise<ResolveResult>;
36
+ export function readDimensions(bytes: Uint8Array): { width: number; height: number } | null;
package/index.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import m from './index.cjs';
2
+ export const {
3
+ SIZES, FALLBACK_ORDER, HOSTS,
4
+ getVideoId, thumbnailUrl, thumbnailUrls, frameUrl,
5
+ checkThumbnail, resolveThumbnail, readDimensions
6
+ } = m;
7
+ export default m;
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "youtube-thumbnail-url",
3
+ "version": "0.1.0",
4
+ "description": "Build every YouTube thumbnail URL (maxresdefault, sddefault, hqdefault, WebP, Shorts oar2) and resolve the largest one that really exists, with a fallback that checks HTTP status and real pixel width.",
5
+ "keywords": ["youtube", "thumbnail", "thumbnails", "ytimg", "maxresdefault", "video", "image", "url", "shorts", "webp"],
6
+ "homepage": "https://thumbnailsgrabber.com/youtube-thumbnail-url-guide",
7
+ "license": "MIT",
8
+ "author": "ThumbnailsGrabber (https://thumbnailsgrabber.com)",
9
+ "main": "./index.cjs",
10
+ "module": "./index.mjs",
11
+ "types": "./index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./index.d.ts",
15
+ "import": "./index.mjs",
16
+ "require": "./index.cjs"
17
+ }
18
+ },
19
+ "files": ["index.cjs", "index.mjs", "index.d.ts", "README.md", "LICENSE"],
20
+ "sideEffects": false,
21
+ "engines": { "node": ">=18" },
22
+ "scripts": {
23
+ "test": "node --test test/*.test.cjs",
24
+ "test:live": "LIVE=1 node --test test/*.test.cjs"
25
+ }
26
+ }