diff options
| author | Astounds <kirito@disroot.org> | 2026-03-27 19:22:12 -0500 |
|---|---|---|
| committer | Astounds <kirito@disroot.org> | 2026-03-27 19:22:12 -0500 |
| commit | 56ecd6cb1b461bd3622c669936050fa7e4d83542 (patch) | |
| tree | a67dc3b8dc75ea20a00e25162949626bd1c6f2a8 | |
| parent | f629565e77ae7178950fceaac7ba650b97bd4c51 (diff) | |
| download | yt-local-56ecd6cb1b461bd3622c669936050fa7e4d83542.tar.lz yt-local-56ecd6cb1b461bd3622c669936050fa7e4d83542.tar.xz yt-local-56ecd6cb1b461bd3622c669936050fa7e4d83542.zip | |
fix: use YouTube-provided thumbnail URLs instead of hardcoded hq720.jpg
Videos without hq720.jpg thumbnails caused mass 404 errors.
Now preserves the actual thumbnail URL from YouTube's API response,
falls back to hqdefault.jpg only when no thumbnail is provided.
Also picks highest quality thumbnail from API (thumbnails[-1])
and adds progressive fallback for subscription/download functions.
| -rw-r--r-- | server.py | 1 | ||||
| -rw-r--r-- | youtube/channel.py | 12 | ||||
| -rw-r--r-- | youtube/comments.py | 2 | ||||
| -rw-r--r-- | youtube/playlist.py | 4 | ||||
| -rw-r--r-- | youtube/static/js/common.js | 33 | ||||
| -rw-r--r-- | youtube/subscriptions.py | 26 | ||||
| -rw-r--r-- | youtube/util.py | 38 | ||||
| -rw-r--r-- | youtube/watch.py | 18 | ||||
| -rw-r--r-- | youtube/yt_data_extract/common.py | 6 | ||||
| -rw-r--r-- | youtube/yt_data_extract/everything_else.py | 2 |
10 files changed, 81 insertions, 61 deletions
@@ -99,7 +99,6 @@ def proxy_site(env, start_response, video=False): if response.status >= 400: print('Error: YouTube returned "%d %s" while routing %s' % ( response.status, response.reason, url.split('?')[0])) - total_received = 0 retry = False while True: diff --git a/youtube/channel.py b/youtube/channel.py index 72fac07..55c1124 100644 --- a/youtube/channel.py +++ b/youtube/channel.py @@ -406,12 +406,12 @@ def post_process_channel_info(info): info['avatar'] = util.prefix_url(info['avatar']) info['channel_url'] = util.prefix_url(info['channel_url']) for item in info['items']: - # For playlists, use first_video_id for thumbnail, not playlist id - if item.get('type') == 'playlist' and item.get('first_video_id'): - item['thumbnail'] = "https://i.ytimg.com/vi/{}/hq720.jpg".format(item['first_video_id']) - elif item.get('type') == 'video': - item['thumbnail'] = "https://i.ytimg.com/vi/{}/hq720.jpg".format(item['id']) - # For channels and other types, keep existing thumbnail + # Only set thumbnail if YouTube didn't provide one + if not item.get('thumbnail'): + if item.get('type') == 'playlist' and item.get('first_video_id'): + item['thumbnail'] = "https://i.ytimg.com/vi/{}/hqdefault.jpg".format(item['first_video_id']) + elif item.get('type') == 'video' and item.get('id'): + item['thumbnail'] = "https://i.ytimg.com/vi/{}/hqdefault.jpg".format(item['id']) util.prefix_urls(item) util.add_extra_html_info(item) if info['current_tab'] == 'about': diff --git a/youtube/comments.py b/youtube/comments.py index 5e40b14..1ff1a21 100644 --- a/youtube/comments.py +++ b/youtube/comments.py @@ -150,7 +150,7 @@ def post_process_comments_info(comments_info): util.URL_ORIGIN, '/watch?v=', comments_info['video_id']) comments_info['video_thumbnail'] = concat_or_none( settings.img_prefix, 'https://i.ytimg.com/vi/', - comments_info['video_id'], '/hq720.jpg' + comments_info['video_id'], '/hqdefault.jpg' ) diff --git a/youtube/playlist.py b/youtube/playlist.py index 2765a30..bedf2d2 100644 --- a/youtube/playlist.py +++ b/youtube/playlist.py @@ -106,8 +106,8 @@ def get_playlist_page(): for item in info.get('items', ()): util.prefix_urls(item) util.add_extra_html_info(item) - if 'id' in item: - item['thumbnail'] = f"{settings.img_prefix}https://i.ytimg.com/vi/{item['id']}/hq720.jpg" + if 'id' in item and not item.get('thumbnail'): + item['thumbnail'] = f"{settings.img_prefix}https://i.ytimg.com/vi/{item['id']}/hqdefault.jpg" item['url'] += '&list=' + playlist_id if item['index']: diff --git a/youtube/static/js/common.js b/youtube/static/js/common.js index bcd1539..ac4413b 100644 --- a/youtube/static/js/common.js +++ b/youtube/static/js/common.js @@ -121,11 +121,12 @@ window.addEventListener('DOMContentLoaded', function() { * Priority: hq720.jpg -> sddefault.jpg -> hqdefault.jpg -> mqdefault.jpg -> default.jpg */ function thumbnail_fallback(img) { - const src = img.src || img.dataset.src; + // Once src is set (image was loaded or attempted), always work with src + const src = img.src; if (!src) return; // Handle YouTube video thumbnails - if (src.includes('/i.ytimg.com/')) { + if (src.includes('/i.ytimg.com/') || src.includes('/i.ytimg.com%2F')) { // Extract video ID from URL const match = src.match(/\/vi\/([^/]+)/); if (!match) return; @@ -138,36 +139,32 @@ function thumbnail_fallback(img) { 'hq720.jpg', 'sddefault.jpg', 'hqdefault.jpg', - 'mqdefault.jpg', - 'default.jpg' ]; // Find current quality and try next fallback for (let i = 0; i < fallbacks.length; i++) { if (src.includes(fallbacks[i])) { - // Try next quality if (i < fallbacks.length - 1) { - const newSrc = imgPrefix + 'https://i.ytimg.com/vi/' + videoId + '/' + fallbacks[i + 1]; - if (img.dataset.src) { - img.dataset.src = newSrc; - } else { - img.src = newSrc; - } + img.src = imgPrefix + 'https://i.ytimg.com/vi/' + videoId + '/' + fallbacks[i + 1]; + } else { + // Last fallback failed, stop retrying + img.onerror = null; } - break; + return; } } + // Unknown quality format, stop retrying + img.onerror = null; } // Handle YouTube channel avatars (ggpht.com) else if (src.includes('ggpht.com') || src.includes('yt3.ggpht.com')) { - // Try to increase avatar size (s88 -> s240) const newSrc = src.replace(/=s\d+-c-k/, '=s240-c-k-c0x00ffffff-no-rj'); if (newSrc !== src) { - if (img.dataset.src) { - img.dataset.src = newSrc; - } else { - img.src = newSrc; - } + img.src = newSrc; + } else { + img.onerror = null; } + } else { + img.onerror = null; } } diff --git a/youtube/subscriptions.py b/youtube/subscriptions.py index 0cb5e95..3326a51 100644 --- a/youtube/subscriptions.py +++ b/youtube/subscriptions.py @@ -1089,12 +1089,26 @@ def serve_subscription_thumbnail(thumbnail): f.close() return flask.Response(image, mimetype='image/jpeg') - url = f"https://i.ytimg.com/vi/{video_id}/hq720.jpg" - try: - image = util.fetch_url(url, report_text="Saved thumbnail: " + video_id) - except urllib.error.HTTPError as e: - print("Failed to download thumbnail for " + video_id + ": " + str(e)) - flask.abort(e.code) + image = None + for quality in ('hq720.jpg', 'sddefault.jpg', 'hqdefault.jpg'): + url = f"https://i.ytimg.com/vi/{video_id}/{quality}" + try: + image = util.fetch_url(url, report_text="Saved thumbnail: " + video_id) + break + except util.FetchError as e: + if '404' in str(e): + continue + print("Failed to download thumbnail for " + video_id + ": " + str(e)) + flask.abort(500) + except urllib.error.HTTPError as e: + if e.code == 404: + continue + print("Failed to download thumbnail for " + video_id + ": " + str(e)) + flask.abort(e.code) + + if image is None: + flask.abort(404) + try: f = open(thumbnail_path, 'wb') except FileNotFoundError: diff --git a/youtube/util.py b/youtube/util.py index e6f1961..ae948ae 100644 --- a/youtube/util.py +++ b/youtube/util.py @@ -542,21 +542,31 @@ class RateLimitedQueue(gevent.queue.Queue): def download_thumbnail(save_directory, video_id): - url = f"https://i.ytimg.com/vi/{video_id}/hq720.jpg" save_location = os.path.join(save_directory, video_id + ".jpg") - try: - thumbnail = fetch_url(url, report_text="Saved thumbnail: " + video_id) - except urllib.error.HTTPError as e: - print("Failed to download thumbnail for " + video_id + ": " + str(e)) - return False - try: - f = open(save_location, 'wb') - except FileNotFoundError: - os.makedirs(save_directory, exist_ok=True) - f = open(save_location, 'wb') - f.write(thumbnail) - f.close() - return True + for quality in ('hq720.jpg', 'sddefault.jpg', 'hqdefault.jpg'): + url = f"https://i.ytimg.com/vi/{video_id}/{quality}" + try: + thumbnail = fetch_url(url, report_text="Saved thumbnail: " + video_id) + except FetchError as e: + if '404' in str(e): + continue + print("Failed to download thumbnail for " + video_id + ": " + str(e)) + return False + except urllib.error.HTTPError as e: + if e.code == 404: + continue + print("Failed to download thumbnail for " + video_id + ": " + str(e)) + return False + try: + f = open(save_location, 'wb') + except FileNotFoundError: + os.makedirs(save_directory, exist_ok=True) + f = open(save_location, 'wb') + f.write(thumbnail) + f.close() + return True + print("No thumbnail available for " + video_id) + return False def download_thumbnails(save_directory, ids): diff --git a/youtube/watch.py b/youtube/watch.py index 14f1dae..b76a462 100644 --- a/youtube/watch.py +++ b/youtube/watch.py @@ -628,12 +628,12 @@ def get_watch_page(video_id=None): # prefix urls, and other post-processing not handled by yt_data_extract for item in info['related_videos']: - # For playlists, use first_video_id for thumbnail, not playlist id - if item.get('type') == 'playlist' and item.get('first_video_id'): - item['thumbnail'] = "https://i.ytimg.com/vi/{}/hq720.jpg".format(item['first_video_id']) - elif item.get('type') == 'video': - item['thumbnail'] = "https://i.ytimg.com/vi/{}/hq720.jpg".format(item['id']) - # For other types, keep existing thumbnail or skip + # Only set thumbnail if YouTube didn't provide one + if not item.get('thumbnail'): + if item.get('type') == 'playlist' and item.get('first_video_id'): + item['thumbnail'] = "https://i.ytimg.com/vi/{}/hqdefault.jpg".format(item['first_video_id']) + elif item.get('type') == 'video' and item.get('id'): + item['thumbnail'] = "https://i.ytimg.com/vi/{}/hqdefault.jpg".format(item['id']) util.prefix_urls(item) util.add_extra_html_info(item) for song in info['music_list']: @@ -641,9 +641,9 @@ def get_watch_page(video_id=None): if info['playlist']: playlist_id = info['playlist']['id'] for item in info['playlist']['items']: - # Set high quality thumbnail for playlist videos - if item.get('type') == 'video' and item.get('id'): - item['thumbnail'] = "https://i.ytimg.com/vi/{}/hq720.jpg".format(item['id']) + # Only set thumbnail if YouTube didn't provide one + if not item.get('thumbnail') and item.get('type') == 'video' and item.get('id'): + item['thumbnail'] = "https://i.ytimg.com/vi/{}/hqdefault.jpg".format(item['id']) util.prefix_urls(item) util.add_extra_html_info(item) if playlist_id: diff --git a/youtube/yt_data_extract/common.py b/youtube/yt_data_extract/common.py index 6a98280..7d44fae 100644 --- a/youtube/yt_data_extract/common.py +++ b/youtube/yt_data_extract/common.py @@ -369,9 +369,9 @@ def extract_item_info(item, additional_info={}): ['detailedMetadataSnippets', 0, 'snippetText'], )) info['thumbnail'] = normalize_url(multi_deep_get(item, - ['thumbnail', 'thumbnails', 0, 'url'], # videos - ['thumbnails', 0, 'thumbnails', 0, 'url'], # playlists - ['thumbnailRenderer', 'showCustomThumbnailRenderer', 'thumbnail', 'thumbnails', 0, 'url'], # shows + ['thumbnail', 'thumbnails', -1, 'url'], # videos (highest quality) + ['thumbnails', 0, 'thumbnails', -1, 'url'], # playlists + ['thumbnailRenderer', 'showCustomThumbnailRenderer', 'thumbnail', 'thumbnails', -1, 'url'], # shows )) info['badges'] = [] diff --git a/youtube/yt_data_extract/everything_else.py b/youtube/yt_data_extract/everything_else.py index 1f5b6a2..0f64649 100644 --- a/youtube/yt_data_extract/everything_else.py +++ b/youtube/yt_data_extract/everything_else.py @@ -229,7 +229,7 @@ def extract_playlist_metadata(polymer_json): if metadata['first_video_id'] is None: metadata['thumbnail'] = None else: - metadata['thumbnail'] = f"https://i.ytimg.com/vi/{metadata['first_video_id']}/hq720.jpg" + metadata['thumbnail'] = f"https://i.ytimg.com/vi/{metadata['first_video_id']}/hqdefault.jpg" metadata['video_count'] = extract_int(header.get('numVideosText')) metadata['description'] = extract_str(header.get('descriptionText'), default='') |
