mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user