fix(youtube thumbnails): prefer best available image

Prefer the best available YouTube thumbnail, fall back cleanly when high-resolution images are unavailable, and keep the React Doctor PR gate scoped to newly introduced issues.
This commit is contained in:
Tommaso Casaburi
2026-06-13 13:39:22 +07:00
committed by GitHub
parent c3d72e53cc
commit 6cb28b0e4f
16 changed files with 813 additions and 199 deletions
+98 -8
View File
@@ -4,6 +4,7 @@ const testState = vi.hoisted(() => ({
cachedThumbnails: new Map<string, string>(),
canEmbedHosts: new Set<string>(),
capacitorHttpGetMock: vi.fn(),
capacitorHttpRequestMock: vi.fn(),
consoleErrorMock: vi.fn(),
fetchMock: vi.fn(),
isNativePlatform: false,
@@ -34,10 +35,12 @@ vi.mock('@capacitor/core', () => ({
},
CapacitorHttp: {
get: (options: unknown) => testState.capacitorHttpGetMock(options),
request: (options: unknown) => testState.capacitorHttpRequestMock(options),
},
}));
import {
getBestAvailableYouTubeThumbnailUrlFromLink,
fetchWebpageThumbnailIfNeeded,
getCommentMediaInfo,
getDisplayMediaInfoType,
@@ -47,7 +50,10 @@ import {
getPostMediaTypeLabel,
getTwimgMediaFilePublishUrl,
getYouTubeEmbedPostMediaFileLink,
getYouTubeThumbnailCandidateUrlsFromLink,
getYouTubeThumbnailFallbackUrls,
getYouTubeThumbnailUrlFromLink,
isMissingYouTubeThumbnailImage,
} from '../media-utils';
const clearMemoizedCache = (fn: unknown) => {
@@ -78,6 +84,20 @@ const createFetchResponse = (html: string, ok = true) => {
};
};
const createHeadResponse = (ok: boolean, contentLength = '12345') => ({
headers: {
get: (name: string) => (name.toLowerCase() === 'content-length' ? contentLength : null),
},
ok,
});
const createNativeHeadResponse = (status: number, contentLength = '12345') => ({
headers: {
'Content-Length': contentLength,
},
status,
});
describe('media-utils', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
@@ -92,6 +112,7 @@ describe('media-utils', () => {
});
testState.fetchMock.mockReset();
testState.capacitorHttpGetMock.mockReset();
testState.capacitorHttpRequestMock.mockReset();
vi.stubGlobal('fetch', testState.fetchMock);
clearMemoizedCache(getHasThumbnail);
clearMemoizedCache(getLinkMediaInfo);
@@ -119,18 +140,87 @@ describe('media-utils', () => {
it('uses the youtube thumbnail url for post media file links and labels', () => {
const mediaInfo = {
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
type: 'iframe',
url: 'https://www.youtube.com/watch?v=abc123',
};
expect(getYouTubeEmbedPostMediaFileLink(mediaInfo)).toBe('https://img.youtube.com/vi/abc123/0.jpg');
expect(getYouTubeEmbedPostMediaFileLink(mediaInfo)).toBe('https://img.youtube.com/vi/abc123/maxresdefault.jpg');
expect(getPostMediaTypeLabel(mediaInfo, 'iframe', (key) => key)).toBe('youtube_video');
expect(getYouTubeEmbedPostMediaFileLink({ type: 'iframe', url: 'https://streamable.com/clip123' })).toBeUndefined();
expect(getYouTubeThumbnailUrlFromLink('https://youtu.be/short123')).toBe('https://img.youtube.com/vi/short123/0.jpg');
expect(getYouTubeThumbnailUrlFromLink('https://youtu.be/short123')).toBe('https://img.youtube.com/vi/short123/maxresdefault.jpg');
expect(getYouTubeThumbnailUrlFromLink('https://example.com/watch?v=not-youtube')).toBeUndefined();
});
it('builds youtube thumbnail candidates and detects the served missing-thumbnail placeholder', () => {
expect(getYouTubeThumbnailCandidateUrlsFromLink('https://www.youtube.com/watch?v=abc123')).toEqual([
'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
'https://img.youtube.com/vi/abc123/sddefault.jpg',
'https://img.youtube.com/vi/abc123/mqdefault.jpg',
'https://img.youtube.com/vi/abc123/hqdefault.jpg',
]);
expect(getYouTubeThumbnailFallbackUrls('https://i3.ytimg.com/vi/abc123/maxresdefault.jpg')).toEqual([
'https://i3.ytimg.com/vi/abc123/maxresdefault.jpg',
'https://img.youtube.com/vi/abc123/sddefault.jpg',
'https://img.youtube.com/vi/abc123/mqdefault.jpg',
'https://img.youtube.com/vi/abc123/hqdefault.jpg',
]);
expect(getYouTubeThumbnailFallbackUrls('https://i3.ytimg.com/vi/abc123/sddefault.jpg')).toEqual([
'https://i3.ytimg.com/vi/abc123/sddefault.jpg',
'https://img.youtube.com/vi/abc123/mqdefault.jpg',
'https://img.youtube.com/vi/abc123/hqdefault.jpg',
]);
expect(isMissingYouTubeThumbnailImage('https://i3.ytimg.com/vi/abc123/maxresdefault.jpg', 120, 90)).toBe(true);
expect(isMissingYouTubeThumbnailImage('https://i3.ytimg.com/vi/abc123/maxresdefault.jpg', 1280, 720)).toBe(false);
expect(isMissingYouTubeThumbnailImage('https://example.com/thumb.jpg', 120, 90)).toBe(false);
});
it('resolves the best available youtube thumbnail without accepting the failed placeholder response', async () => {
testState.fetchMock
.mockResolvedValueOnce(createHeadResponse(false, '1097'))
.mockResolvedValueOnce(createHeadResponse(true, '1097'))
.mockResolvedValueOnce(createHeadResponse(true, '8710'));
await expect(getBestAvailableYouTubeThumbnailUrlFromLink('https://www.youtube.com/watch?v=resolve123')).resolves.toBe(
'https://img.youtube.com/vi/resolve123/mqdefault.jpg',
);
expect(testState.fetchMock).toHaveBeenNthCalledWith(1, 'https://img.youtube.com/vi/resolve123/maxresdefault.jpg', expect.objectContaining({ method: 'HEAD' }));
expect(testState.fetchMock).toHaveBeenNthCalledWith(2, 'https://img.youtube.com/vi/resolve123/sddefault.jpg', expect.objectContaining({ method: 'HEAD' }));
expect(testState.fetchMock).toHaveBeenNthCalledWith(3, 'https://img.youtube.com/vi/resolve123/mqdefault.jpg', expect.objectContaining({ method: 'HEAD' }));
});
it('uses native http requests when resolving youtube thumbnails in native builds', async () => {
testState.isNativePlatform = true;
testState.capacitorHttpRequestMock.mockResolvedValueOnce(createNativeHeadResponse(404, '1097')).mockResolvedValueOnce(createNativeHeadResponse(200, '8765'));
await expect(getBestAvailableYouTubeThumbnailUrlFromLink('https://www.youtube.com/watch?v=native123')).resolves.toBe(
'https://img.youtube.com/vi/native123/sddefault.jpg',
);
expect(testState.fetchMock).not.toHaveBeenCalled();
expect(testState.capacitorHttpRequestMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
connectTimeout: 3000,
method: 'HEAD',
readTimeout: 3000,
url: 'https://img.youtube.com/vi/native123/maxresdefault.jpg',
}),
);
expect(testState.capacitorHttpRequestMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: 'HEAD',
url: 'https://img.youtube.com/vi/native123/sddefault.jpg',
}),
);
});
it('returns no youtube thumbnail when every candidate is unavailable', async () => {
testState.fetchMock.mockResolvedValue(createHeadResponse(false, '1097'));
await expect(getBestAvailableYouTubeThumbnailUrlFromLink('https://www.youtube.com/watch?v=missing123')).resolves.toBeUndefined();
});
it('recognizes which media types expose thumbnails', () => {
expect(getHasThumbnail(undefined, 'https://example.com/file.png')).toBe(false);
expect(getHasThumbnail({ type: 'image', url: 'https://example.com/file.png' }, 'https://example.com/file.png')).toBe(true);
@@ -141,7 +231,7 @@ describe('media-utils', () => {
expect(getHasThumbnail({ thumbnail: 'https://example.com/thumb.png', type: 'webpage', url: 'https://example.com' }, 'https://example.com')).toBe(true);
expect(
getHasThumbnail(
{ patternThumbnailUrl: 'https://img.youtube.com/vi/abc/0.jpg', type: 'iframe', url: 'https://www.youtube.com/watch?v=abc' },
{ patternThumbnailUrl: 'https://img.youtube.com/vi/abc/maxresdefault.jpg', type: 'iframe', url: 'https://www.youtube.com/watch?v=abc' },
'https://www.youtube.com/watch?v=abc',
),
).toBe(true);
@@ -171,7 +261,7 @@ describe('media-utils', () => {
expect(getLinkMediaInfo('https://example.com/file.swf')).toMatchObject({ type: 'swf' });
expect(getLinkMediaInfo('https://example.com/path')).toMatchObject({ type: 'webpage' });
expect(getLinkMediaInfo('https://www.youtube.com/watch?v=abc123')).toEqual({
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
type: 'iframe',
url: 'https://www.youtube.com/watch?v=abc123',
});
@@ -181,13 +271,13 @@ describe('media-utils', () => {
url: 'https://streamable.com/clip123',
});
expect(getLinkMediaInfo('https://yt.example/watch?v=yt123')).toEqual({
patternThumbnailUrl: 'https://img.youtube.com/vi/yt123/0.jpg',
patternThumbnailUrl: 'https://img.youtube.com/vi/yt123/maxresdefault.jpg',
type: 'iframe',
url: 'https://yt.example/watch?v=yt123',
});
testState.canEmbedHosts = new Set(['yewtu.be']);
expect(getLinkMediaInfo('https://yewtu.be/invidious123')).toEqual({
patternThumbnailUrl: 'https://img.youtube.com/vi/invidious123/0.jpg',
patternThumbnailUrl: 'https://img.youtube.com/vi/invidious123/maxresdefault.jpg',
type: 'iframe',
url: 'https://yewtu.be/invidious123',
});
@@ -237,7 +327,7 @@ describe('media-utils', () => {
expect(getCommentMediaInfo('https://www.youtube.com/watch?v=abc123', '', 800, 450)).toEqual({
linkHeight: 450,
linkWidth: 800,
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
thumbnail: undefined,
type: 'iframe',
url: 'https://www.youtube.com/watch?v=abc123',
+132 -5
View File
@@ -66,19 +66,146 @@ export const getPostMediaTypeLabel = (commentMediaInfo: CommentMediaInfo | undef
};
const isYouTubeLikeUrl = (url: URL): boolean => youtubeHosts.has(url.host) || url.host.startsWith('yt.');
const YOUTUBE_THUMBNAIL_FILENAMES = ['maxresdefault.jpg', 'sddefault.jpg', 'mqdefault.jpg', 'hqdefault.jpg'] as const;
const YOUTUBE_MISSING_THUMBNAIL_CONTENT_LENGTH = '1097';
const YOUTUBE_MISSING_THUMBNAIL_HEIGHT = 90;
const YOUTUBE_MISSING_THUMBNAIL_WIDTH = 120;
const YOUTUBE_THUMBNAIL_RESOLUTION_TIMEOUT_MS = 3000;
const youtubeThumbnailResolutionPromises = new Map<string, Promise<string | undefined>>();
export const getYouTubeThumbnailUrl = (url: URL): string | undefined => {
if (!isYouTubeLikeUrl(url)) {
const getHeaderValue = (headers: Record<string, string>, headerName: string): string | undefined => {
const matchingHeader = Object.entries(headers).find(([name]) => name.toLowerCase() === headerName);
return matchingHeader?.[1];
};
const getYouTubeThumbnailUrlFromVideoId = (videoId: string, filename: (typeof YOUTUBE_THUMBNAIL_FILENAMES)[number]): string => {
return `https://img.youtube.com/vi/${videoId}/${filename}`;
};
const getYouTubeThumbnailVideoId = (url: URL): string | undefined => {
const hostname = url.hostname.toLowerCase();
if (hostname !== 'img.youtube.com' && hostname !== 'i.ytimg.com' && !/^i\d+\.ytimg\.com$/.test(hostname)) {
return undefined;
}
const pathParts = url.pathname.split('/').filter(Boolean);
if (pathParts.length !== 3 || pathParts[0] !== 'vi') {
return undefined;
}
return pathParts[1];
};
export const getYouTubeThumbnailCandidateUrls = (url: URL): string[] => {
if (!isYouTubeLikeUrl(url)) {
return [];
}
const videoId = getYouTubeVideoId(url);
return videoId ? `https://img.youtube.com/vi/${videoId}/0.jpg` : undefined;
return videoId ? YOUTUBE_THUMBNAIL_FILENAMES.map((filename) => getYouTubeThumbnailUrlFromVideoId(videoId, filename)) : [];
};
export const getYouTubeThumbnailCandidateUrlsFromLink = (link: string): string[] => {
const parsedUrl = parseHttpUrl(link.trim());
return parsedUrl ? getYouTubeThumbnailCandidateUrls(parsedUrl) : [];
};
export const getYouTubeThumbnailFallbackUrls = (thumbnailUrl: string | undefined): string[] => {
if (!thumbnailUrl) {
return [];
}
const parsedUrl = parseHttpUrl(thumbnailUrl);
const videoId = parsedUrl ? getYouTubeThumbnailVideoId(parsedUrl) : undefined;
if (!videoId) {
return [thumbnailUrl];
}
const currentFilename = parsedUrl?.pathname.split('/').filter(Boolean)[2];
const startIndex = YOUTUBE_THUMBNAIL_FILENAMES.findIndex((filename) => filename === currentFilename);
const filenames = startIndex >= 0 ? YOUTUBE_THUMBNAIL_FILENAMES.slice(startIndex) : YOUTUBE_THUMBNAIL_FILENAMES;
return filenames.map((filename) => (filename === currentFilename ? thumbnailUrl : getYouTubeThumbnailUrlFromVideoId(videoId, filename)));
};
export const isMissingYouTubeThumbnailImage = (thumbnailUrl: string, width: number, height: number): boolean => {
const parsedUrl = parseHttpUrl(thumbnailUrl);
return Boolean(parsedUrl && getYouTubeThumbnailVideoId(parsedUrl) && width === YOUTUBE_MISSING_THUMBNAIL_WIDTH && height === YOUTUBE_MISSING_THUMBNAIL_HEIGHT);
};
export const getYouTubeThumbnailUrl = (url: URL): string | undefined => {
return getYouTubeThumbnailCandidateUrls(url)[0];
};
export const getYouTubeThumbnailUrlFromLink = (link: string): string | undefined => {
const parsedUrl = parseHttpUrl(link.trim());
return parsedUrl ? getYouTubeThumbnailUrl(parsedUrl) : undefined;
return getYouTubeThumbnailCandidateUrlsFromLink(link)[0];
};
const isAvailableYouTubeThumbnailUrl = async (thumbnailUrl: string): Promise<boolean> => {
if (Capacitor.isNativePlatform()) {
try {
const response = await CapacitorHttp.request({
url: thumbnailUrl,
method: 'HEAD',
readTimeout: YOUTUBE_THUMBNAIL_RESOLUTION_TIMEOUT_MS,
connectTimeout: YOUTUBE_THUMBNAIL_RESOLUTION_TIMEOUT_MS,
});
return response.status >= 200 && response.status < 300 && getHeaderValue(response.headers, 'content-length') !== YOUTUBE_MISSING_THUMBNAIL_CONTENT_LENGTH;
} catch {
return false;
}
}
if (typeof fetch !== 'function') {
return false;
}
const controller = new AbortController();
const timeoutId = globalThis.setTimeout(() => controller.abort(), YOUTUBE_THUMBNAIL_RESOLUTION_TIMEOUT_MS);
try {
const response = await fetch(thumbnailUrl, {
method: 'HEAD',
signal: controller.signal,
});
return response.ok && response.headers.get('content-length') !== YOUTUBE_MISSING_THUMBNAIL_CONTENT_LENGTH;
} catch {
return false;
} finally {
globalThis.clearTimeout(timeoutId);
}
};
export const getBestAvailableYouTubeThumbnailUrlFromLink = (link: string): Promise<string | undefined> => {
const candidateUrls = getYouTubeThumbnailCandidateUrlsFromLink(link);
if (!candidateUrls.length) {
return Promise.resolve(undefined);
}
const cacheKey = candidateUrls.join('\n');
const cachedPromise = youtubeThumbnailResolutionPromises.get(cacheKey);
if (cachedPromise) {
return cachedPromise;
}
const resolutionPromise = (async () => {
for (const candidateUrl of candidateUrls) {
if (await isAvailableYouTubeThumbnailUrl(candidateUrl)) {
return candidateUrl;
}
}
return undefined;
})().then((thumbnailUrl) => {
if (!thumbnailUrl) {
youtubeThumbnailResolutionPromises.delete(cacheKey);
}
return thumbnailUrl;
});
youtubeThumbnailResolutionPromises.set(cacheKey, resolutionPromise);
return resolutionPromise;
};
export const getHasThumbnail = memoize(