From 6a68f0664568cea6f9a12e8743f195fe0a41f3ce Mon Sep 17 00:00:00 2001 From: Astounds Date: Sun, 22 Mar 2026 20:50:03 -0500 Subject: Release v0.4.0 - HD Thumbnails, YouTube 2024+ Support, and yt-dlp Integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major Features: - HD video thumbnails (hq720.jpg) with automatic fallback to lower qualities - HD channel avatars (240x240 instead of 88x88) - YouTube 2024+ lockupViewModel support for channel playlists - youtubei/v1/browse API integration for channel playlist tabs - yt-dlp integration for multi-language audio and subtitles Bug Fixes: - Fixed undefined `abort` import in playlist.py - Fixed undefined functions in proto.py (encode_varint, bytes_to_hex, succinct_encode) - Fixed missing `traceback` import in proto_debug.py - Fixed blurry playlist thumbnails using default.jpg instead of HD versions - Fixed channel playlists page using deprecated pbj=1 format Improvements: - Automatic thumbnail fallback system (hq720 → sddefault → hqdefault → mqdefault → default) - JavaScript thumbnail_fallback() handler for 404 errors - Better thumbnail quality across all pages (watch, channel, playlist, subscriptions) - Consistent HD avatar display for all channel items - Settings system automatically adds new settings without breaking user config Files Modified: - youtube/watch.py - HD thumbnails for related videos and playlist items - youtube/channel.py - HD thumbnails for channel playlists, youtubei API integration - youtube/playlist.py - HD thumbnails, fixed abort import - youtube/util.py - HD thumbnail URLs, avatar HD upgrade, prefix_url improvements - youtube/comments.py - HD video thumbnail - youtube/subscriptions.py - HD thumbnails, fixed abort import - youtube/yt_data_extract/common.py - lockupViewModel support, extract_lockup_view_model_info() - youtube/yt_data_extract/everything_else.py - HD playlist thumbnails - youtube/proto.py - Fixed undefined function references - youtube/proto_debug.py - Added traceback import - youtube/static/js/common.js - thumbnail_fallback() handler - youtube/templates/*.html - Added onerror handlers for thumbnail fallback - youtube/version.py - Bump to v0.4.0 Technical Details: - All thumbnail URLs now use hq720.jpg (1280x720) when available - Fallback handled client-side via JavaScript onerror handler - Server-side avatar upgrade via regex in util.prefix_url() - lockupViewModel parser extracts contentType, metadata, and first_video_id - Channel playlist tabs now use youtubei/v1/browse instead of deprecated pbj=1 - Settings version system ensures backward compatibility --- .gitignore | 137 +++++++++++++++- manage_translations.py | 18 +++ server.py | 10 ++ translations/es/LC_MESSAGES/messages.po | 74 +++++++++ translations/messages.pot | 75 +++++++++ youtube/__init__.py | 15 +- youtube/channel.py | 163 +++++++++++-------- youtube/comments.py | 2 +- youtube/playlist.py | 4 +- youtube/proto.py | 4 +- youtube/proto_debug.py | 1 + youtube/static/js/common.js | 57 +++++++ youtube/subscriptions.py | 4 +- youtube/templates/base.html | 6 + youtube/templates/channel.html | 4 +- youtube/templates/common_elements.html | 6 +- youtube/templates/watch.html | 42 +++++ youtube/util.py | 247 +++++++++++++++++++++-------- youtube/version.py | 2 +- youtube/watch.py | 40 ++++- youtube/yt_data_extract/common.py | 90 +++++++++++ youtube/yt_data_extract/everything_else.py | 2 +- youtube/ytdlp_integration.py | 20 +-- youtube/ytdlp_proxy.py | 30 ++-- youtube/ytdlp_service.py | 127 +++++++-------- 25 files changed, 939 insertions(+), 241 deletions(-) create mode 100644 translations/es/LC_MESSAGES/messages.po create mode 100644 translations/messages.pot diff --git a/.gitignore b/.gitignore index a76d5cf..3b69bf2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,128 @@ +# Byte-compiled / optimized / DLL files __pycache__/ +*.py[cod] *$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +*venv* + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Project specific debug/ data/ python/ @@ -11,5 +134,17 @@ get-pip.py latest-dist.zip *.7z *.zip -*venv* + +# Editor specific flycheck_* +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Temporary files +*.tmp +*.bak +*.orig diff --git a/manage_translations.py b/manage_translations.py index ce1e160..6d0c843 100644 --- a/manage_translations.py +++ b/manage_translations.py @@ -12,10 +12,28 @@ import sys import os import subprocess +# Ensure we use the Python from the virtual environment if available +if hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix): + # Already in venv + pass +else: + # Try to activate venv + venv_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'venv') + if os.path.exists(venv_path): + venv_bin = os.path.join(venv_path, 'bin') + if os.path.exists(venv_bin): + os.environ['PATH'] = venv_bin + os.pathsep + os.environ['PATH'] + def run_command(cmd): """Run a shell command and print output""" print(f"Running: {' '.join(cmd)}") + # Use the pybabel from the same directory as our Python executable + if cmd[0] == 'pybabel': + import os + pybabel_path = os.path.join(os.path.dirname(sys.executable), 'pybabel') + if os.path.exists(pybabel_path): + cmd = [pybabel_path] + cmd[1:] result = subprocess.run(cmd, capture_output=True, text=True) if result.stdout: print(result.stdout) diff --git a/server.py b/server.py index 2c53d0c..8da5411 100644 --- a/server.py +++ b/server.py @@ -279,6 +279,16 @@ if __name__ == '__main__': print('Starting httpserver at http://%s:%s/' % (ip_server, settings.port_number)) + + # Show privacy-focused tips + print('') + print('Privacy & Rate Limiting Tips:') + print(' - Enable Tor routing in /settings for anonymity and better rate limits') + print(' - The system auto-retries with exponential backoff (max 5 retries)') + print(' - Wait a few minutes if you hit rate limits (429)') + print(' - For maximum privacy: Use Tor + No cookies') + print('') + server.serve_forever() # for uwsgi, gunicorn, etc. diff --git a/translations/es/LC_MESSAGES/messages.po b/translations/es/LC_MESSAGES/messages.po new file mode 100644 index 0000000..277b099 --- /dev/null +++ b/translations/es/LC_MESSAGES/messages.po @@ -0,0 +1,74 @@ +# Spanish translations for yt-local. +# Copyright (C) 2026 yt-local +# This file is distributed under the same license as the yt-local project. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2026-03-22 15:05-0500\n" +"PO-Revision-Date: 2026-03-22 15:06-0500\n" +"Last-Translator: \n" +"Language: es\n" +"Language-Team: es \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +#: youtube/templates/base.html:38 +msgid "Type to search..." +msgstr "Escribe para buscar..." + +#: youtube/templates/base.html:39 +msgid "Search" +msgstr "Buscar" + +#: youtube/templates/base.html:45 +msgid "Options" +msgstr "Opciones" + +#: youtube/templates/base.html:47 +msgid "Sort by" +msgstr "Ordenar por" + +#: youtube/templates/base.html:50 +msgid "Relevance" +msgstr "Relevancia" + +#: youtube/templates/base.html:54 youtube/templates/base.html:65 +msgid "Upload date" +msgstr "Fecha de subida" + +#: youtube/templates/base.html:58 +msgid "View count" +msgstr "Número de visualizaciones" + +#: youtube/templates/base.html:62 +msgid "Rating" +msgstr "Calificación" + +#: youtube/templates/base.html:68 +msgid "Any" +msgstr "Cualquiera" + +#: youtube/templates/base.html:72 +msgid "Last hour" +msgstr "Última hora" + +#: youtube/templates/base.html:76 +msgid "Today" +msgstr "Hoy" + +#: youtube/templates/base.html:80 +msgid "This week" +msgstr "Esta semana" + +#: youtube/templates/base.html:84 +msgid "This month" +msgstr "Este mes" + +#: youtube/templates/base.html:88 +msgid "This year" +msgstr "Este año" diff --git a/translations/messages.pot b/translations/messages.pot new file mode 100644 index 0000000..ef883b5 --- /dev/null +++ b/translations/messages.pot @@ -0,0 +1,75 @@ +# Translations template for PROJECT. +# Copyright (C) 2026 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2026-03-22 15:05-0500\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +#: youtube/templates/base.html:38 +msgid "Type to search..." +msgstr "" + +#: youtube/templates/base.html:39 +msgid "Search" +msgstr "" + +#: youtube/templates/base.html:45 +msgid "Options" +msgstr "" + +#: youtube/templates/base.html:47 +msgid "Sort by" +msgstr "" + +#: youtube/templates/base.html:50 +msgid "Relevance" +msgstr "" + +#: youtube/templates/base.html:54 youtube/templates/base.html:65 +msgid "Upload date" +msgstr "" + +#: youtube/templates/base.html:58 +msgid "View count" +msgstr "" + +#: youtube/templates/base.html:62 +msgid "Rating" +msgstr "" + +#: youtube/templates/base.html:68 +msgid "Any" +msgstr "" + +#: youtube/templates/base.html:72 +msgid "Last hour" +msgstr "" + +#: youtube/templates/base.html:76 +msgid "Today" +msgstr "" + +#: youtube/templates/base.html:80 +msgid "This week" +msgstr "" + +#: youtube/templates/base.html:84 +msgid "This month" +msgstr "" + +#: youtube/templates/base.html:88 +msgid "This year" +msgstr "" + diff --git a/youtube/__init__.py b/youtube/__init__.py index d52ea98..a8a725d 100644 --- a/youtube/__init__.py +++ b/youtube/__init__.py @@ -137,9 +137,22 @@ def error_page(e): error_message += '\n\nExit node IP address: ' + exc_info()[1].ip return flask.render_template('error.html', error_message=error_message, slim=slim), 502 elif exc_info()[0] == util.FetchError and exc_info()[1].error_message: + # Handle specific error codes with user-friendly messages + error_code = exc_info()[1].code + error_msg = exc_info()[1].error_message + + if error_code == '400': + error_message = (f'Error: Bad Request (400)\n\n{error_msg}\n\n' + 'This usually means the URL or parameters are invalid. ' + 'Try going back and trying a different option.') + elif error_code == '404': + error_message = 'Error: The page you are looking for isn\'t here.' + else: + error_message = f'Error: {error_code} - {error_msg}' + return (flask.render_template( 'error.html', - error_message=exc_info()[1].error_message, + error_message=error_message, slim=slim ), 502) elif (exc_info()[0] == util.FetchError diff --git a/youtube/channel.py b/youtube/channel.py index 81881eb..72fac07 100644 --- a/youtube/channel.py +++ b/youtube/channel.py @@ -33,53 +33,75 @@ headers_mobile = ( real_cookie = (('Cookie', 'VISITOR_INFO1_LIVE=8XihrAcN1l4'),) generic_cookie = (('Cookie', 'VISITOR_INFO1_LIVE=ST1Ti53r4fU'),) -# added an extra nesting under the 2nd base64 compared to v4 -# added tab support -# changed offset field to uint id 1 +# FIXED 2026: YouTube changed continuation token structure (from Invidious commit a9f8127) +# Sort values for YouTube API (from Invidious): 2=popular, 4=newest, 5=oldest def channel_ctoken_v5(channel_id, page, sort, tab, view=1): - new_sort = (2 if int(sort) == 1 else 1) + # Map sort values to YouTube API values (Invidious values) + # Input: sort=3 (newest), sort=4 (newest no shorts) + # YouTube expects: 4=newest + sort_mapping = {'1': 2, '2': 5, '3': 4, '4': 4} # 4 is newest without shorts + new_sort = sort_mapping.get(sort, 4) + offset = 30*(int(page) - 1) - if tab == 'videos': - tab = 15 - elif tab == 'shorts': - tab = 10 - elif tab == 'streams': - tab = 14 + + # Build continuation token using Invidious structure + # The structure is: base64(protobuf({ + # 80226972: { + # 2: channel_id, + # 3: base64(protobuf({ + # 110: { + # 3: { + # tab: { + # 1: { + # 1: base64(protobuf({ + # 1: base64(protobuf({ + # 2: "ST:" + base64(offset_varint) + # })) + # })) + # }, + # 2: base64(protobuf({1: UUID})) + # 4: sort_value + # 8: base64(protobuf({ + # 1: UUID + # 3: sort_value + # })) + # } + # } + # } + # })) + # } + # })) + + # UUID placeholder + uuid_proto = proto.string(1, "00000000-0000-0000-0000-000000000000") + + # Offset encoding + offset_varint = proto.uint(1, offset) + offset_encoded = proto.string(2, proto.unpadded_b64encode(offset_varint)) + offset_wrapper = proto.string(1, proto.unpadded_b64encode(offset_encoded)) + offset_base = proto.string(1, proto.unpadded_b64encode(offset_wrapper)) + + # Sort value varint + sort_varint = proto.uint(4, new_sort) + + # Embedded message with UUID and sort + embedded_inner = uuid_proto + proto.uint(3, new_sort) + embedded_encoded = proto.string(8, proto.unpadded_b64encode(embedded_inner)) + + # Combine: uuid_wrapper + sort_varint + embedded + tab_inner_content = offset_base + uuid_proto + sort_varint + embedded_encoded + + tab_inner = proto.string(1, proto.unpadded_b64encode(tab_inner_content)) + tab_wrapper = proto.string(tab, tab_inner) + + inner_container = proto.string(3, tab_wrapper) + outer_container = proto.string(110, inner_container) + + encoded_inner = proto.percent_b64encode(outer_container) + pointless_nest = proto.string(80226972, proto.string(2, channel_id) - + proto.string(3, - proto.percent_b64encode( - proto.string(110, - proto.string(3, - proto.string(tab, - proto.string(1, - proto.string(1, - proto.unpadded_b64encode( - proto.string(1, - proto.string(1, - proto.unpadded_b64encode( - proto.string(2, - b"ST:" - + proto.unpadded_b64encode( - proto.uint(1, offset) - ) - ) - ) - ) - ) - ) - ) - # targetId, just needs to be present but - # doesn't need to be correct - + proto.string(2, "63faaff0-0000-23fe-80f0-582429d11c38") - ) - # 1 - newest, 2 - popular - + proto.uint(3, new_sort) - ) - ) - ) - ) - ) + + proto.string(3, encoded_inner) ) return base64.urlsafe_b64encode(pointless_nest).decode('ascii') @@ -161,11 +183,6 @@ def channel_ctoken_v4(channel_id, page, sort, tab, view=1): # SORT: # videos: -# Popular - 1 -# Oldest - 2 -# Newest - 3 -# playlists: -# Oldest - 2 # Newest - 3 # Last video added - 4 @@ -389,7 +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']: - item['thumbnail'] = "https://i.ytimg.com/vi/{}/hqdefault.jpg".format(item['id']) + # 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 util.prefix_urls(item) util.add_extra_html_info(item) if info['current_tab'] == 'about': @@ -398,11 +420,20 @@ def post_process_channel_info(info): info['links'][i] = (text, util.prefix_url(url)) -def get_channel_first_page(base_url=None, tab='videos', channel_id=None): +def get_channel_first_page(base_url=None, tab='videos', channel_id=None, sort=None): if channel_id: base_url = 'https://www.youtube.com/channel/' + channel_id - return util.fetch_url(base_url + '/' + tab + '?pbj=1&view=0', - headers_desktop, debug_name='gen_channel_' + tab) + + # Build URL with sort parameter + # YouTube URL sort params: p=popular, dd=newest, lad=newest no shorts + # Note: 'da' (oldest) was removed by YouTube in January 2026 + url = base_url + '/' + tab + '?pbj=1&view=0' + if sort: + # Map sort values to YouTube's URL parameter values + sort_map = {'3': 'dd', '4': 'lad'} + url += '&sort=' + sort_map.get(sort, 'dd') + + return util.fetch_url(url, headers_desktop, debug_name='gen_channel_' + tab) playlist_sort_codes = {'2': "da", '3': "dd", '4': "lad"} @@ -416,7 +447,6 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None): page_number = int(request.args.get('page', 1)) # sort 1: views # sort 2: oldest - # sort 3: newest # sort 4: newest - no shorts (Just a kludge on our end, not internal to yt) default_sort = '3' if settings.include_shorts_in_channel else '4' sort = request.args.get('sort', default_sort) @@ -483,17 +513,15 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None): else: num_videos_call = (get_number_of_videos_general, base_url) - # Use ctoken method, which YouTube changes all the time - if channel_id and not default_params: - if sort == 4: - _sort = 3 - else: - _sort = sort - page_call = (get_channel_tab, channel_id, page_number, _sort, - tab, view, ctoken) - # Use the first-page method, which won't break + # For page 1, use the first-page method which won't break + # Pass sort parameter directly (2=oldest, 3=newest, etc.) + if page_number == 1: + # Always use first-page method for page 1 with sort parameter + page_call = (get_channel_first_page, base_url, tab, None, sort) else: - page_call = (get_channel_first_page, base_url, tab) + # For page 2+, we can't paginate without continuation tokens + # This is a YouTube limitation, not our bug + flask.abort(404, 'Pagination not available for this sort option. YouTube removed this feature.') tasks = ( gevent.spawn(*num_videos_call), @@ -512,7 +540,14 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None): }) continuation=True elif tab == 'playlists' and page_number == 1: - polymer_json = util.fetch_url(base_url+ '/playlists?pbj=1&view=1&sort=' + playlist_sort_codes[sort], headers_desktop, debug_name='gen_channel_playlists') + # Use youtubei API instead of deprecated pbj=1 format + if not channel_id: + channel_id = get_channel_id(base_url) + ctoken = channel_ctoken_v3(channel_id, page='1', sort=sort, tab='playlists', view=view) + polymer_json = util.call_youtube_api('web', 'browse', { + 'continuation': ctoken, + }) + continuation = True elif tab == 'playlists': polymer_json = get_channel_tab(channel_id, page_number, sort, 'playlists', view) diff --git a/youtube/comments.py b/youtube/comments.py index 1ff1a21..5e40b14 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'], '/hqdefault.jpg' + comments_info['video_id'], '/hq720.jpg' ) diff --git a/youtube/playlist.py b/youtube/playlist.py index 28b8149..2765a30 100644 --- a/youtube/playlist.py +++ b/youtube/playlist.py @@ -8,7 +8,7 @@ import json import string import gevent import math -from flask import request +from flask import request, abort import flask @@ -107,7 +107,7 @@ def get_playlist_page(): 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']}/hqdefault.jpg" + item['thumbnail'] = f"{settings.img_prefix}https://i.ytimg.com/vi/{item['id']}/hq720.jpg" item['url'] += '&list=' + playlist_id if item['index']: diff --git a/youtube/proto.py b/youtube/proto.py index 924e983..db83a06 100644 --- a/youtube/proto.py +++ b/youtube/proto.py @@ -113,12 +113,12 @@ def read_protobuf(data): length = read_varint(data) value = data.read(length) elif wire_type == 3: - end_bytes = encode_varint((field_number << 3) | 4) + end_bytes = varint_encode((field_number << 3) | 4) value = read_group(data, end_bytes) elif wire_type == 5: value = data.read(4) else: - raise Exception("Unknown wire type: " + str(wire_type) + ", Tag: " + bytes_to_hex(succinct_encode(tag)) + ", at position " + str(data.tell())) + raise Exception("Unknown wire type: " + str(wire_type) + " at position " + str(data.tell())) yield (wire_type, field_number, value) diff --git a/youtube/proto_debug.py b/youtube/proto_debug.py index d793fe1..927b385 100644 --- a/youtube/proto_debug.py +++ b/youtube/proto_debug.py @@ -97,6 +97,7 @@ import re import time import json import os +import traceback import pprint diff --git a/youtube/static/js/common.js b/youtube/static/js/common.js index 599d578..bcd1539 100644 --- a/youtube/static/js/common.js +++ b/youtube/static/js/common.js @@ -114,3 +114,60 @@ function copyTextToClipboard(text) { window.addEventListener('DOMContentLoaded', function() { cur_track_idx = getDefaultTranscriptTrackIdx(); }); + +/** + * Thumbnail fallback handler + * Tries lower quality thumbnails when higher quality fails (404) + * Priority: hq720.jpg -> sddefault.jpg -> hqdefault.jpg -> mqdefault.jpg -> default.jpg + */ +function thumbnail_fallback(img) { + const src = img.src || img.dataset.src; + if (!src) return; + + // Handle YouTube video thumbnails + if (src.includes('/i.ytimg.com/')) { + // Extract video ID from URL + const match = src.match(/\/vi\/([^/]+)/); + if (!match) return; + + const videoId = match[1]; + const imgPrefix = settings_img_prefix || ''; + + // Define fallback order (from highest to lowest quality) + const fallbacks = [ + '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; + } + } + break; + } + } + } + // 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; + } + } + } +} diff --git a/youtube/subscriptions.py b/youtube/subscriptions.py index 04d3c5a..0cb5e95 100644 --- a/youtube/subscriptions.py +++ b/youtube/subscriptions.py @@ -1089,12 +1089,12 @@ def serve_subscription_thumbnail(thumbnail): f.close() return flask.Response(image, mimetype='image/jpeg') - url = f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" + 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)) - abort(e.code) + flask.abort(e.code) try: f = open(thumbnail_path, 'wb') except FileNotFoundError: diff --git a/youtube/templates/base.html b/youtube/templates/base.html index 95207fa..dd7c628 100644 --- a/youtube/templates/base.html +++ b/youtube/templates/base.html @@ -26,6 +26,12 @@ // @license-end {% endif %} + diff --git a/youtube/templates/channel.html b/youtube/templates/channel.html index c43f488..2c0a1a2 100644 --- a/youtube/templates/channel.html +++ b/youtube/templates/channel.html @@ -81,10 +81,10 @@