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 +21 -0
- package/README.md +123 -0
- package/index.cjs +255 -0
- package/index.d.ts +36 -0
- package/index.mjs +7 -0
- package/package.json +26 -0
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
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
|
+
}
|