Some checks failed
CI / test (push) Failing after 1m19s
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
204 lines
6.5 KiB
Python
204 lines
6.5 KiB
Python
from youtube import util
|
|
from .get_app_version import app_version
|
|
import flask
|
|
from flask import request
|
|
import jinja2
|
|
import settings
|
|
import traceback
|
|
import re
|
|
from sys import exc_info
|
|
from flask_babel import Babel
|
|
|
|
yt_app = flask.Flask(__name__)
|
|
yt_app.config['TEMPLATES_AUTO_RELOAD'] = True
|
|
yt_app.url_map.strict_slashes = False
|
|
# yt_app.jinja_env.trim_blocks = True
|
|
# yt_app.jinja_env.lstrip_blocks = True
|
|
|
|
# Configure Babel for i18n
|
|
import os
|
|
yt_app.config['BABEL_DEFAULT_LOCALE'] = 'en'
|
|
# Use absolute path for translations directory to avoid issues with package structure changes
|
|
_app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
yt_app.config['BABEL_TRANSLATION_DIRECTORIES'] = os.path.join(_app_root, 'translations')
|
|
|
|
def get_locale():
|
|
"""Determine the best locale based on user preference or browser settings"""
|
|
# Check if user has a language preference in settings
|
|
if hasattr(settings, 'language') and settings.language:
|
|
locale = settings.language
|
|
print(f'[i18n] Using user preference: {locale}')
|
|
return locale
|
|
# Otherwise, use browser's Accept-Language header
|
|
# Only match languages with available translations
|
|
locale = request.accept_languages.best_match(['en', 'es'])
|
|
print(f'[i18n] Using browser language: {locale}')
|
|
return locale or 'en'
|
|
|
|
babel = Babel(yt_app, locale_selector=get_locale)
|
|
|
|
|
|
yt_app.add_url_rule('/settings', 'settings_page', settings.settings_page, methods=['POST', 'GET'])
|
|
|
|
|
|
@yt_app.route('/')
|
|
def homepage():
|
|
return flask.render_template('home.html', title="YT Local")
|
|
|
|
|
|
@yt_app.route('/licenses')
|
|
def licensepage():
|
|
return flask.render_template(
|
|
'licenses.html',
|
|
title="Licenses - YT Local"
|
|
)
|
|
|
|
|
|
theme_names = {
|
|
0: 'light_theme',
|
|
1: 'gray_theme',
|
|
2: 'dark_theme',
|
|
}
|
|
|
|
|
|
@yt_app.context_processor
|
|
def inject_theme_preference():
|
|
return {
|
|
'theme_path': '/youtube.com/static/' + theme_names[settings.theme] + '.css',
|
|
'settings': settings,
|
|
# Detect version
|
|
'current_version': app_version()['version'],
|
|
'current_branch': app_version()['branch'],
|
|
'current_commit': app_version()['commit'],
|
|
}
|
|
|
|
|
|
@yt_app.template_filter('commatize')
|
|
def commatize(num):
|
|
if num is None:
|
|
return ''
|
|
if isinstance(num, str):
|
|
try:
|
|
num = int(num)
|
|
except ValueError:
|
|
return num
|
|
return '{:,}'.format(num)
|
|
|
|
|
|
def timestamp_replacement(match):
|
|
time_seconds = 0
|
|
for part in match.group(0).split(':'):
|
|
time_seconds = 60*time_seconds + int(part)
|
|
return (
|
|
"""
|
|
<a href="#" id="timestamp%s">%s</a>
|
|
<script>
|
|
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
|
|
(function main() {
|
|
'use strict';
|
|
const player = document.getElementById('js-video-player');
|
|
const a = document.getElementById('timestamp%s');
|
|
a.addEventListener('click', function(event) {
|
|
player.currentTime = %s
|
|
});
|
|
}());
|
|
// @license-end
|
|
</script>
|
|
""" % (
|
|
str(time_seconds),
|
|
match.group(0),
|
|
str(time_seconds),
|
|
str(time_seconds)
|
|
)
|
|
)
|
|
|
|
|
|
TIMESTAMP_RE = re.compile(r'\b(\d?\d:)?\d?\d:\d\d\b')
|
|
|
|
|
|
@yt_app.template_filter('timestamps')
|
|
def timestamps(text):
|
|
return TIMESTAMP_RE.sub(timestamp_replacement, text)
|
|
|
|
|
|
@yt_app.errorhandler(500)
|
|
def error_page(e):
|
|
slim = request.args.get('slim', False) # whether it was an ajax request
|
|
if (exc_info()[0] == util.FetchError
|
|
and exc_info()[1].code == '429'
|
|
and settings.route_tor
|
|
):
|
|
error_message = ('Error: YouTube blocked the request because the Tor'
|
|
' exit node is overutilized. Try getting a new exit node by'
|
|
' using the New Identity button in the Tor Browser.')
|
|
if exc_info()[1].error_message:
|
|
error_message += '\n\n' + exc_info()[1].error_message
|
|
if exc_info()[1].ip:
|
|
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=error_message,
|
|
slim=slim
|
|
), 502)
|
|
elif (exc_info()[0] == util.FetchError
|
|
and exc_info()[1].code == '404'
|
|
):
|
|
error_message = ('Error: The page you are looking for isn\'t here.')
|
|
return flask.render_template('error.html',
|
|
error_code=exc_info()[1].code,
|
|
error_message=error_message,
|
|
slim=slim), 404
|
|
return flask.render_template('error.html', traceback=traceback.format_exc(),
|
|
error_code=exc_info()[1].code,
|
|
slim=slim), 500
|
|
# return flask.render_template('error.html', traceback=traceback.format_exc(), slim=slim), 500
|
|
|
|
|
|
font_choices = {
|
|
0: 'initial',
|
|
1: '"liberation serif", "times new roman", calibri, carlito, serif',
|
|
2: 'arial, "liberation sans", sans-serif',
|
|
3: 'verdana, sans-serif',
|
|
4: 'tahoma, sans-serif',
|
|
}
|
|
|
|
|
|
@yt_app.route('/shared.css')
|
|
def get_css():
|
|
return flask.Response(
|
|
flask.render_template(
|
|
'shared.css',
|
|
font_family=font_choices[settings.font]
|
|
),
|
|
mimetype='text/css',
|
|
)
|
|
|
|
|
|
# This is okay because the flask urlize function puts the href as the first
|
|
# property
|
|
YOUTUBE_LINK_RE = re.compile(r'<a href="(' + util.YOUTUBE_URL_RE_STR + ')"')
|
|
old_urlize = jinja2.filters.urlize
|
|
|
|
|
|
def prefix_urlize(*args, **kwargs):
|
|
result = old_urlize(*args, **kwargs)
|
|
return YOUTUBE_LINK_RE.sub(r'<a href="/\1"', result)
|
|
|
|
|
|
jinja2.filters.urlize = prefix_urlize
|