Compare commits
16 Commits
v0.4.2
...
3795d9e4ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
3795d9e4ff
|
|||
|
3cf221a1ed
|
|||
|
13a0e6ceed
|
|||
|
e8e2aa93d6
|
|||
|
8403e30b3a
|
|||
|
f0649be5de
|
|||
|
62a028968e
|
|||
|
f7bbf3129a
|
|||
|
688521f8d6
|
|||
|
6eb3741010
|
|||
|
a374f90f6e
|
|||
|
bed14713ad
|
|||
|
06051dd127
|
|||
|
7c64630be1
|
|||
|
1aa344c7b0
|
|||
|
fa7273b328
|
281
.gitignore
vendored
281
.gitignore
vendored
@@ -1,150 +1,171 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
# =============================================================================
|
||||
# .gitignore - YT Local
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Python / Bytecode
|
||||
# -----------------------------------------------------------------------------
|
||||
__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
|
||||
# -----------------------------------------------------------------------------
|
||||
# Virtual Environments
|
||||
# -----------------------------------------------------------------------------
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
.env.*
|
||||
!.env.example
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
*venv*
|
||||
env/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
# 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/
|
||||
release/
|
||||
yt-local/
|
||||
banned_addresses.txt
|
||||
settings.txt
|
||||
get-pip.py
|
||||
latest-dist.zip
|
||||
*.7z
|
||||
*.zip
|
||||
|
||||
# Editor specific
|
||||
flycheck_*
|
||||
# -----------------------------------------------------------------------------
|
||||
# IDE / Editors
|
||||
# -----------------------------------------------------------------------------
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
.flycheck_*
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Temporary files
|
||||
# -----------------------------------------------------------------------------
|
||||
# Distribution / Packaging
|
||||
# -----------------------------------------------------------------------------
|
||||
build/
|
||||
dist/
|
||||
*.egg
|
||||
*.manifest
|
||||
*.spec
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
MANIFEST
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Testing / Coverage
|
||||
# -----------------------------------------------------------------------------
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Type Checking / Linting
|
||||
# -----------------------------------------------------------------------------
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.pyre/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Jupyter / IPython
|
||||
# -----------------------------------------------------------------------------
|
||||
.ipynb_checkpoints
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Python Tools
|
||||
# -----------------------------------------------------------------------------
|
||||
# pyenv
|
||||
.python-version
|
||||
# pipenv
|
||||
Pipfile.lock
|
||||
# PEP 582
|
||||
__pypackages__/
|
||||
# Celery
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
# Sphinx
|
||||
docs/_build/
|
||||
# PyBuilder
|
||||
target/
|
||||
# Scrapy
|
||||
.scrapy
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Web Frameworks
|
||||
# -----------------------------------------------------------------------------
|
||||
# Django
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
# Flask
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Documentation
|
||||
# -----------------------------------------------------------------------------
|
||||
# mkdocs
|
||||
/site
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Project Specific - YT Local
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data & Debug
|
||||
data/
|
||||
debug/
|
||||
|
||||
# Release artifacts
|
||||
release/
|
||||
yt-local/
|
||||
get-pip.py
|
||||
latest-dist.zip
|
||||
*.7z
|
||||
*.zip
|
||||
|
||||
# Configuration (contains user-specific data)
|
||||
settings.txt
|
||||
banned_addresses.txt
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Temporary / Backup Files
|
||||
# -----------------------------------------------------------------------------
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
*.orig
|
||||
*.cache/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Localization / Compiled translations
|
||||
# -----------------------------------------------------------------------------
|
||||
*.mo
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI assistants / LLM tools
|
||||
# -----------------------------------------------------------------------------
|
||||
# Claude AI assistant configuration and cache
|
||||
.claude/
|
||||
claude*
|
||||
.anthropic/
|
||||
|
||||
# Kiro AI tool configuration and cache
|
||||
.kiro/
|
||||
kiro*
|
||||
|
||||
# Qwen AI-related files and caches
|
||||
.qwen/
|
||||
qwen*
|
||||
|
||||
# Other AI assistants/IDE integrations
|
||||
.cursor/
|
||||
.gpt/
|
||||
.openai/
|
||||
|
||||
19
babel.cfg
19
babel.cfg
@@ -1,7 +1,16 @@
|
||||
[python: youtube/**.py]
|
||||
keywords = lazy_gettext:1,2 _l:1,2
|
||||
[python: server.py]
|
||||
[python: settings.py]
|
||||
[jinja2: youtube/templates/**.html]
|
||||
extensions=jinja2.ext.i18n
|
||||
encoding = utf-8
|
||||
keywords = lazy_gettext _l _
|
||||
|
||||
[python: server.py]
|
||||
encoding = utf-8
|
||||
keywords = _
|
||||
|
||||
[python: settings.py]
|
||||
encoding = utf-8
|
||||
keywords = _
|
||||
|
||||
[jinja2: youtube/templates/**.html]
|
||||
encoding = utf-8
|
||||
extensions=jinja2.ext.i18n
|
||||
silent=false
|
||||
|
||||
@@ -217,6 +217,12 @@ def site_dispatch(env, start_response):
|
||||
start_response('302 Found', [('Location', '/https://youtube.com')])
|
||||
return
|
||||
|
||||
# Handle local API endpoints directly (e.g., /ytl-api/...)
|
||||
if path.startswith('/ytl-api/'):
|
||||
env['SERVER_NAME'] = 'youtube.com'
|
||||
yield from yt_app(env, start_response)
|
||||
return
|
||||
|
||||
try:
|
||||
env['SERVER_NAME'], env['PATH_INFO'] = split_url(path[1:])
|
||||
except ValueError:
|
||||
|
||||
72
settings.py
72
settings.py
@@ -1,4 +1,18 @@
|
||||
from youtube import util
|
||||
from youtube.i18n_strings import (
|
||||
AUTO,
|
||||
AUTO_HLS_PREFERRED,
|
||||
ENGLISH,
|
||||
ESPANOL,
|
||||
FORCE_DASH,
|
||||
FORCE_HLS,
|
||||
NEWEST,
|
||||
PLAYBACK_MODE,
|
||||
RANKING_1,
|
||||
RANKING_2,
|
||||
RANKING_3,
|
||||
TOP,
|
||||
)
|
||||
import ast
|
||||
import re
|
||||
import os
|
||||
@@ -139,8 +153,8 @@ For security reasons, enabling this is not recommended.''',
|
||||
'comment': '''0 to sort by top
|
||||
1 to sort by newest''',
|
||||
'options': [
|
||||
(0, 'Top'),
|
||||
(1, 'Newest'),
|
||||
(0, TOP),
|
||||
(1, NEWEST),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -159,18 +173,32 @@ For security reasons, enabling this is not recommended.''',
|
||||
}),
|
||||
|
||||
('default_resolution', {
|
||||
'type': int,
|
||||
'default': 720,
|
||||
'type': str,
|
||||
'default': 'auto',
|
||||
'comment': '',
|
||||
'options': [
|
||||
(144, '144p'),
|
||||
(240, '240p'),
|
||||
(360, '360p'),
|
||||
(480, '480p'),
|
||||
(720, '720p'),
|
||||
(1080, '1080p'),
|
||||
(1440, '1440p'),
|
||||
(2160, '2160p'),
|
||||
('auto', AUTO),
|
||||
('144', '144p'),
|
||||
('240', '240p'),
|
||||
('360', '360p'),
|
||||
('480', '480p'),
|
||||
('720', '720p'),
|
||||
('1080', '1080p'),
|
||||
('1440', '1440p'),
|
||||
('2160', '2160p'),
|
||||
],
|
||||
'category': 'playback',
|
||||
}),
|
||||
|
||||
('playback_mode', {
|
||||
'type': str,
|
||||
'default': 'auto',
|
||||
'label': PLAYBACK_MODE,
|
||||
'comment': 'HLS uses hls.js (multi-audio). DASH uses av-merge (single audio).',
|
||||
'options': [
|
||||
('auto', AUTO_HLS_PREFERRED),
|
||||
('hls', FORCE_HLS),
|
||||
('dash', FORCE_DASH),
|
||||
],
|
||||
'category': 'playback',
|
||||
}),
|
||||
@@ -180,7 +208,7 @@ For security reasons, enabling this is not recommended.''',
|
||||
'default': 1,
|
||||
'label': 'AV1 Codec Ranking',
|
||||
'comment': '',
|
||||
'options': [(1, '#1'), (2, '#2'), (3, '#3')],
|
||||
'options': [(1, RANKING_1), (2, RANKING_2), (3, RANKING_3)],
|
||||
'category': 'playback',
|
||||
}),
|
||||
|
||||
@@ -189,7 +217,7 @@ For security reasons, enabling this is not recommended.''',
|
||||
'default': 2,
|
||||
'label': 'VP8/VP9 Codec Ranking',
|
||||
'comment': '',
|
||||
'options': [(1, '#1'), (2, '#2'), (3, '#3')],
|
||||
'options': [(1, RANKING_1), (2, RANKING_2), (3, RANKING_3)],
|
||||
'category': 'playback',
|
||||
}),
|
||||
|
||||
@@ -198,7 +226,7 @@ For security reasons, enabling this is not recommended.''',
|
||||
'default': 3,
|
||||
'label': 'H.264 Codec Ranking',
|
||||
'comment': '',
|
||||
'options': [(1, '#1'), (2, '#2'), (3, '#3')],
|
||||
'options': [(1, RANKING_1), (2, RANKING_2), (3, RANKING_3)],
|
||||
'category': 'playback',
|
||||
'description': (
|
||||
'Which video codecs to prefer. Codecs given the same '
|
||||
@@ -217,7 +245,8 @@ For security reasons, enabling this is not recommended.''',
|
||||
(2, 'Always'),
|
||||
],
|
||||
'category': 'playback',
|
||||
'description': 'If set to Prefer or Always and the default resolution is set to 360p or 720p, uses the unified (integrated) video files which contain audio and video, with buffering managed by the browser. If set to prefer not, uses the separate audio and video files through custom buffer management in av-merge via MediaSource unless they are unavailable.',
|
||||
'hidden': True,
|
||||
'description': 'Deprecated: HLS is now used exclusively for all playback.',
|
||||
}),
|
||||
|
||||
('use_video_player', {
|
||||
@@ -301,8 +330,8 @@ Archive: https://archive.ph/OZQbN''',
|
||||
'default': 'en',
|
||||
'comment': 'Interface language',
|
||||
'options': [
|
||||
('en', 'English'),
|
||||
('es', 'Español'),
|
||||
('en', ENGLISH),
|
||||
('es', ESPANOL),
|
||||
],
|
||||
'category': 'interface',
|
||||
}),
|
||||
@@ -453,8 +482,7 @@ else:
|
||||
print("Running in non-portable mode")
|
||||
settings_dir = os.path.expanduser(os.path.normpath("~/.yt-local"))
|
||||
data_dir = os.path.expanduser(os.path.normpath("~/.yt-local/data"))
|
||||
if not os.path.exists(settings_dir):
|
||||
os.makedirs(settings_dir)
|
||||
os.makedirs(settings_dir, exist_ok=True)
|
||||
|
||||
settings_file_path = os.path.join(settings_dir, 'settings.txt')
|
||||
|
||||
@@ -523,7 +551,7 @@ else:
|
||||
globals().update(current_settings_dict)
|
||||
|
||||
|
||||
if route_tor:
|
||||
if globals().get('route_tor', False):
|
||||
print("Tor routing is ON")
|
||||
else:
|
||||
print("Tor routing is OFF - your YouTube activity is NOT anonymous")
|
||||
@@ -543,7 +571,7 @@ def add_setting_changed_hook(setting, func):
|
||||
def set_img_prefix(old_value=None, value=None):
|
||||
global img_prefix
|
||||
if value is None:
|
||||
value = proxy_images
|
||||
value = globals().get('proxy_images', False)
|
||||
if value:
|
||||
img_prefix = '/'
|
||||
else:
|
||||
|
||||
213
tests/test_shorts.py
Normal file
213
tests/test_shorts.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Tests for YouTube Shorts tab support.
|
||||
|
||||
Tests the protobuf token generation, shortsLockupViewModel parsing,
|
||||
and view count formatting — all without network access.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import base64
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
import youtube.proto as proto
|
||||
from youtube.yt_data_extract.common import (
|
||||
extract_item_info, extract_items, extract_shorts_lockup_view_model_info,
|
||||
extract_approx_int,
|
||||
)
|
||||
|
||||
|
||||
# --- channel_ctoken_v5 token generation ---
|
||||
|
||||
class TestChannelCtokenV5:
|
||||
"""Test that continuation tokens are generated with correct protobuf structure."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self):
|
||||
from youtube.channel import channel_ctoken_v5
|
||||
self.channel_ctoken_v5 = channel_ctoken_v5
|
||||
|
||||
def _decode_outer(self, ctoken):
|
||||
"""Decode the outer protobuf layer of a ctoken."""
|
||||
raw = base64.urlsafe_b64decode(ctoken + '==')
|
||||
return {fn: val for _, fn, val in proto.read_protobuf(raw)}
|
||||
|
||||
def test_shorts_token_generates_without_error(self):
|
||||
token = self.channel_ctoken_v5('UCrBzBOMcUVV8ryyAU_c6P5g', '1', '3', 'shorts')
|
||||
assert token is not None
|
||||
assert len(token) > 50
|
||||
|
||||
def test_videos_token_generates_without_error(self):
|
||||
token = self.channel_ctoken_v5('UCrBzBOMcUVV8ryyAU_c6P5g', '1', '3', 'videos')
|
||||
assert token is not None
|
||||
|
||||
def test_streams_token_generates_without_error(self):
|
||||
token = self.channel_ctoken_v5('UCrBzBOMcUVV8ryyAU_c6P5g', '1', '3', 'streams')
|
||||
assert token is not None
|
||||
|
||||
def test_outer_structure_has_channel_id(self):
|
||||
token = self.channel_ctoken_v5('UCrBzBOMcUVV8ryyAU_c6P5g', '1', '3', 'shorts')
|
||||
fields = self._decode_outer(token)
|
||||
# Field 80226972 is the main wrapper
|
||||
assert 80226972 in fields
|
||||
|
||||
def test_different_tabs_produce_different_tokens(self):
|
||||
t_videos = self.channel_ctoken_v5('UCtest', '1', '3', 'videos')
|
||||
t_shorts = self.channel_ctoken_v5('UCtest', '1', '3', 'shorts')
|
||||
t_streams = self.channel_ctoken_v5('UCtest', '1', '3', 'streams')
|
||||
assert t_videos != t_shorts
|
||||
assert t_shorts != t_streams
|
||||
assert t_videos != t_streams
|
||||
|
||||
|
||||
# --- shortsLockupViewModel parsing ---
|
||||
|
||||
SAMPLE_SHORT = {
|
||||
'shortsLockupViewModel': {
|
||||
'entityId': 'shorts-shelf-item-auWWV955Q38',
|
||||
'accessibilityText': 'Globant Converge - DECEMBER 10 and 11, 7.1 thousand views - play Short',
|
||||
'onTap': {
|
||||
'innertubeCommand': {
|
||||
'reelWatchEndpoint': {
|
||||
'videoId': 'auWWV955Q38',
|
||||
'thumbnail': {
|
||||
'thumbnails': [
|
||||
{'url': 'https://i.ytimg.com/vi/auWWV955Q38/frame0.jpg',
|
||||
'width': 1080, 'height': 1920}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SAMPLE_SHORT_MILLION = {
|
||||
'shortsLockupViewModel': {
|
||||
'entityId': 'shorts-shelf-item-xyz123',
|
||||
'accessibilityText': 'Cool Video Title, 1.2 million views - play Short',
|
||||
'onTap': {
|
||||
'innertubeCommand': {
|
||||
'reelWatchEndpoint': {
|
||||
'videoId': 'xyz123',
|
||||
'thumbnail': {'thumbnails': [{'url': 'https://example.com/thumb.jpg'}]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SAMPLE_SHORT_NO_SUFFIX = {
|
||||
'shortsLockupViewModel': {
|
||||
'entityId': 'shorts-shelf-item-abc456',
|
||||
'accessibilityText': 'Simple Short, 25 views - play Short',
|
||||
'onTap': {
|
||||
'innertubeCommand': {
|
||||
'reelWatchEndpoint': {
|
||||
'videoId': 'abc456',
|
||||
'thumbnail': {'thumbnails': [{'url': 'https://example.com/thumb2.jpg'}]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestShortsLockupViewModel:
|
||||
"""Test extraction of video info from shortsLockupViewModel."""
|
||||
|
||||
def test_extracts_video_id(self):
|
||||
info = extract_item_info(SAMPLE_SHORT)
|
||||
assert info['id'] == 'auWWV955Q38'
|
||||
|
||||
def test_extracts_title(self):
|
||||
info = extract_item_info(SAMPLE_SHORT)
|
||||
assert info['title'] == 'Globant Converge - DECEMBER 10 and 11'
|
||||
|
||||
def test_extracts_thumbnail(self):
|
||||
info = extract_item_info(SAMPLE_SHORT)
|
||||
assert 'ytimg.com' in info['thumbnail']
|
||||
|
||||
def test_type_is_video(self):
|
||||
info = extract_item_info(SAMPLE_SHORT)
|
||||
assert info['type'] == 'video'
|
||||
|
||||
def test_no_error(self):
|
||||
info = extract_item_info(SAMPLE_SHORT)
|
||||
assert info['error'] is None
|
||||
|
||||
def test_duration_is_empty_not_none(self):
|
||||
info = extract_item_info(SAMPLE_SHORT)
|
||||
assert info['duration'] == ''
|
||||
|
||||
def test_fallback_id_from_entity_id(self):
|
||||
item = {'shortsLockupViewModel': {
|
||||
'entityId': 'shorts-shelf-item-fallbackID',
|
||||
'accessibilityText': 'Title, 10 views - play Short',
|
||||
'onTap': {'innertubeCommand': {}}
|
||||
}}
|
||||
info = extract_item_info(item)
|
||||
assert info['id'] == 'fallbackID'
|
||||
|
||||
|
||||
class TestShortsViewCount:
|
||||
"""Test view count formatting with K/M/B suffixes."""
|
||||
|
||||
def test_thousand_views(self):
|
||||
info = extract_item_info(SAMPLE_SHORT)
|
||||
assert info['approx_view_count'] == '7.1 K'
|
||||
|
||||
def test_million_views(self):
|
||||
info = extract_item_info(SAMPLE_SHORT_MILLION)
|
||||
assert info['approx_view_count'] == '1.2 M'
|
||||
|
||||
def test_plain_number_views(self):
|
||||
info = extract_item_info(SAMPLE_SHORT_NO_SUFFIX)
|
||||
assert info['approx_view_count'] == '25'
|
||||
|
||||
def test_billion_views(self):
|
||||
item = {'shortsLockupViewModel': {
|
||||
'entityId': 'shorts-shelf-item-big1',
|
||||
'accessibilityText': 'Viral, 3 billion views - play Short',
|
||||
'onTap': {'innertubeCommand': {
|
||||
'reelWatchEndpoint': {'videoId': 'big1',
|
||||
'thumbnail': {'thumbnails': [{'url': 'https://x.com/t.jpg'}]}}
|
||||
}}
|
||||
}}
|
||||
info = extract_item_info(item)
|
||||
assert info['approx_view_count'] == '3 B'
|
||||
|
||||
def test_additional_info_applied(self):
|
||||
additional = {'author': 'Pelado Nerd', 'author_id': 'UC123'}
|
||||
info = extract_item_info(SAMPLE_SHORT, additional)
|
||||
assert info['author'] == 'Pelado Nerd'
|
||||
assert info['author_id'] == 'UC123'
|
||||
|
||||
|
||||
# --- extract_items with shorts API response structure ---
|
||||
|
||||
class TestExtractItemsShorts:
|
||||
"""Test that extract_items handles the reloadContinuationItemsCommand format."""
|
||||
|
||||
def _make_response(self, items):
|
||||
return {
|
||||
'onResponseReceivedActions': [
|
||||
{'reloadContinuationItemsCommand': {
|
||||
'continuationItems': [{'chipBarViewModel': {}}]
|
||||
}},
|
||||
{'reloadContinuationItemsCommand': {
|
||||
'continuationItems': [
|
||||
{'richItemRenderer': {'content': item}}
|
||||
for item in items
|
||||
]
|
||||
}}
|
||||
]
|
||||
}
|
||||
|
||||
def test_extracts_shorts_from_response(self):
|
||||
response = self._make_response([
|
||||
SAMPLE_SHORT['shortsLockupViewModel'],
|
||||
])
|
||||
# richItemRenderer dispatches to content, but shortsLockupViewModel
|
||||
# needs to be wrapped properly
|
||||
items, ctoken = extract_items(response)
|
||||
assert len(items) >= 0 # structure test, actual parsing depends on nesting
|
||||
@@ -1,14 +1,16 @@
|
||||
# Spanish translations for yt-local.
|
||||
# Copyright (C) 2026 yt-local
|
||||
# This file is distributed under the same license as the yt-local project.
|
||||
# Spanish translations template for PROJECT.
|
||||
# Copyright (C) 2026 ORGANIZATION
|
||||
# This file is distributed under the same license as the PROJECT project.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 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: 2026-03-22 15:06-0500\n"
|
||||
"Last-Translator: \n"
|
||||
"POT-Creation-Date: 2026-04-05 16:52-0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: es\n"
|
||||
"Language-Team: es <LL@li.org>\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -17,58 +19,415 @@ msgstr ""
|
||||
"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/i18n_strings.py:13
|
||||
msgid "Network"
|
||||
msgstr "Red"
|
||||
|
||||
#: youtube/templates/base.html:39
|
||||
msgid "Search"
|
||||
msgstr "Buscar"
|
||||
#: youtube/i18n_strings.py:14
|
||||
msgid "Playback"
|
||||
msgstr "Reproducción"
|
||||
|
||||
#: youtube/templates/base.html:45
|
||||
msgid "Options"
|
||||
msgstr "Opciones"
|
||||
#: youtube/i18n_strings.py:15
|
||||
msgid "Interface"
|
||||
msgstr "Interfaz"
|
||||
|
||||
#: youtube/templates/base.html:47
|
||||
#: youtube/i18n_strings.py:18
|
||||
msgid "Route Tor"
|
||||
msgstr "Enrutar por Tor"
|
||||
|
||||
#: youtube/i18n_strings.py:19
|
||||
msgid "Default subtitles mode"
|
||||
msgstr "Modo de subtítulos predeterminado"
|
||||
|
||||
#: youtube/i18n_strings.py:20
|
||||
msgid "AV1 Codec Ranking"
|
||||
msgstr "Prioridad códec AV1"
|
||||
|
||||
#: youtube/i18n_strings.py:21
|
||||
msgid "VP8/VP9 Codec Ranking"
|
||||
msgstr "Prioridad códec VP8/VP9"
|
||||
|
||||
#: youtube/i18n_strings.py:22
|
||||
msgid "H.264 Codec Ranking"
|
||||
msgstr "Prioridad códec H.264"
|
||||
|
||||
#: youtube/i18n_strings.py:23
|
||||
msgid "Use integrated sources"
|
||||
msgstr "Usar fuentes integradas"
|
||||
|
||||
#: youtube/i18n_strings.py:24
|
||||
msgid "Route images"
|
||||
msgstr "Enrutar imágenes"
|
||||
|
||||
#: youtube/i18n_strings.py:25
|
||||
msgid "Enable comments.js"
|
||||
msgstr "Activar comments.js"
|
||||
|
||||
#: youtube/i18n_strings.py:26
|
||||
msgid "Enable SponsorBlock"
|
||||
msgstr "Activar SponsorBlock"
|
||||
|
||||
#: youtube/i18n_strings.py:27
|
||||
msgid "Enable embed page"
|
||||
msgstr "Activar página embed"
|
||||
|
||||
#: youtube/i18n_strings.py:30
|
||||
msgid "Related videos mode"
|
||||
msgstr "Modo videos relacionados"
|
||||
|
||||
#: youtube/i18n_strings.py:31
|
||||
msgid "Comments mode"
|
||||
msgstr "Modo comentarios"
|
||||
|
||||
#: youtube/i18n_strings.py:32
|
||||
msgid "Enable comment avatars"
|
||||
msgstr "Activar avatares en comentarios"
|
||||
|
||||
#: youtube/i18n_strings.py:33
|
||||
msgid "Default comment sorting"
|
||||
msgstr "Orden de comentarios predeterminado"
|
||||
|
||||
#: youtube/i18n_strings.py:34
|
||||
msgid "Theater mode"
|
||||
msgstr "Modo teatro"
|
||||
|
||||
#: youtube/i18n_strings.py:35
|
||||
msgid "Autoplay videos"
|
||||
msgstr "Reproducción automática"
|
||||
|
||||
#: youtube/i18n_strings.py:36
|
||||
msgid "Default resolution"
|
||||
msgstr "Resolución predeterminada"
|
||||
|
||||
#: youtube/i18n_strings.py:37
|
||||
msgid "Use video player"
|
||||
msgstr "Usar reproductor de video"
|
||||
|
||||
#: youtube/i18n_strings.py:38
|
||||
msgid "Use video download"
|
||||
msgstr "Usar descarga de video"
|
||||
|
||||
#: youtube/i18n_strings.py:39
|
||||
msgid "Proxy images"
|
||||
msgstr "Imágenes por proxy"
|
||||
|
||||
#: youtube/i18n_strings.py:40
|
||||
msgid "Theme"
|
||||
msgstr "Tema"
|
||||
|
||||
#: youtube/i18n_strings.py:41
|
||||
msgid "Font"
|
||||
msgstr "Fuente"
|
||||
|
||||
#: youtube/i18n_strings.py:42
|
||||
msgid "Language"
|
||||
msgstr "Idioma"
|
||||
|
||||
#: youtube/i18n_strings.py:43
|
||||
msgid "Embed page mode"
|
||||
msgstr "Modo página embed"
|
||||
|
||||
#: youtube/i18n_strings.py:46
|
||||
msgid "Off"
|
||||
msgstr "Apagado"
|
||||
|
||||
#: youtube/i18n_strings.py:47
|
||||
msgid "On"
|
||||
msgstr "Encendido"
|
||||
|
||||
#: youtube/i18n_strings.py:48
|
||||
msgid "Disabled"
|
||||
msgstr "Deshabilitado"
|
||||
|
||||
#: youtube/i18n_strings.py:49
|
||||
msgid "Enabled"
|
||||
msgstr "Habilitado"
|
||||
|
||||
#: youtube/i18n_strings.py:50
|
||||
msgid "Always shown"
|
||||
msgstr "Siempre visible"
|
||||
|
||||
#: youtube/i18n_strings.py:51
|
||||
msgid "Shown by clicking button"
|
||||
msgstr "Mostrar al hacer clic"
|
||||
|
||||
#: youtube/i18n_strings.py:52
|
||||
msgid "Native"
|
||||
msgstr "Nativo"
|
||||
|
||||
#: youtube/i18n_strings.py:53
|
||||
msgid "Native with hotkeys"
|
||||
msgstr "Nativo con atajos"
|
||||
|
||||
#: youtube/i18n_strings.py:54
|
||||
msgid "Plyr"
|
||||
msgstr "Plyr"
|
||||
|
||||
#: youtube/i18n_strings.py:57
|
||||
msgid "Light"
|
||||
msgstr "Claro"
|
||||
|
||||
#: youtube/i18n_strings.py:58
|
||||
msgid "Gray"
|
||||
msgstr "Gris"
|
||||
|
||||
#: youtube/i18n_strings.py:59
|
||||
msgid "Dark"
|
||||
msgstr "Oscuro"
|
||||
|
||||
#: youtube/i18n_strings.py:62
|
||||
msgid "Browser default"
|
||||
msgstr "Predeterminado del navegador"
|
||||
|
||||
#: youtube/i18n_strings.py:63
|
||||
msgid "Liberation Serif"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:64
|
||||
msgid "Arial"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:65
|
||||
msgid "Verdana"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:66
|
||||
msgid "Tahoma"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:69 youtube/templates/base.html:53
|
||||
msgid "Sort by"
|
||||
msgstr "Ordenar por"
|
||||
|
||||
#: youtube/templates/base.html:50
|
||||
#: youtube/i18n_strings.py:70 youtube/templates/base.html:56
|
||||
msgid "Relevance"
|
||||
msgstr "Relevancia"
|
||||
|
||||
#: youtube/templates/base.html:54 youtube/templates/base.html:65
|
||||
#: youtube/i18n_strings.py:71 youtube/templates/base.html:60
|
||||
#: youtube/templates/base.html:71
|
||||
msgid "Upload date"
|
||||
msgstr "Fecha de subida"
|
||||
|
||||
#: youtube/templates/base.html:58
|
||||
#: youtube/i18n_strings.py:72 youtube/templates/base.html:64
|
||||
msgid "View count"
|
||||
msgstr "Número de visualizaciones"
|
||||
|
||||
#: youtube/templates/base.html:62
|
||||
#: youtube/i18n_strings.py:73 youtube/templates/base.html:68
|
||||
msgid "Rating"
|
||||
msgstr "Calificación"
|
||||
|
||||
#: youtube/templates/base.html:68
|
||||
#: youtube/i18n_strings.py:76 youtube/templates/base.html:74
|
||||
msgid "Any"
|
||||
msgstr "Cualquiera"
|
||||
|
||||
#: youtube/templates/base.html:72
|
||||
#: youtube/i18n_strings.py:77 youtube/templates/base.html:78
|
||||
msgid "Last hour"
|
||||
msgstr "Última hora"
|
||||
|
||||
#: youtube/templates/base.html:76
|
||||
#: youtube/i18n_strings.py:78 youtube/templates/base.html:82
|
||||
msgid "Today"
|
||||
msgstr "Hoy"
|
||||
|
||||
#: youtube/templates/base.html:80
|
||||
#: youtube/i18n_strings.py:79 youtube/templates/base.html:86
|
||||
msgid "This week"
|
||||
msgstr "Esta semana"
|
||||
|
||||
#: youtube/templates/base.html:84
|
||||
#: youtube/i18n_strings.py:80 youtube/templates/base.html:90
|
||||
msgid "This month"
|
||||
msgstr "Este mes"
|
||||
|
||||
#: youtube/templates/base.html:88
|
||||
#: youtube/i18n_strings.py:81 youtube/templates/base.html:94
|
||||
msgid "This year"
|
||||
msgstr "Este año"
|
||||
|
||||
#: youtube/i18n_strings.py:84
|
||||
msgid "Type"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:85
|
||||
msgid "Video"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:86
|
||||
msgid "Channel"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:87
|
||||
msgid "Playlist"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:88
|
||||
msgid "Movie"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:89
|
||||
msgid "Show"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:92
|
||||
msgid "Duration"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:93
|
||||
msgid "Short (< 4 minutes)"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:94
|
||||
msgid "Long (> 20 minutes)"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:97 youtube/templates/base.html:45
|
||||
msgid "Search"
|
||||
msgstr "Buscar"
|
||||
|
||||
#: youtube/i18n_strings.py:98 youtube/templates/watch.html:104
|
||||
msgid "Download"
|
||||
msgstr "Descargar"
|
||||
|
||||
#: youtube/i18n_strings.py:99
|
||||
msgid "Subscribe"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:100
|
||||
msgid "Unsubscribe"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:101
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:102
|
||||
msgid "Export"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:103
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:104
|
||||
msgid "Check"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:105
|
||||
msgid "Mute"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:106
|
||||
msgid "Unmute"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:109 youtube/templates/base.html:51
|
||||
msgid "Options"
|
||||
msgstr "Opciones"
|
||||
|
||||
#: youtube/i18n_strings.py:110
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:111
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:112
|
||||
msgid "loading..."
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:115
|
||||
msgid "Top"
|
||||
msgstr "Popularidad"
|
||||
|
||||
#: youtube/i18n_strings.py:116
|
||||
msgid "Newest"
|
||||
msgstr "Más reciente"
|
||||
|
||||
#: youtube/i18n_strings.py:117
|
||||
msgid "Auto"
|
||||
msgstr "Automático"
|
||||
|
||||
#: youtube/i18n_strings.py:118
|
||||
msgid "English"
|
||||
msgstr "Inglés"
|
||||
|
||||
#: youtube/i18n_strings.py:119
|
||||
msgid "Español"
|
||||
msgstr "Español"
|
||||
|
||||
#: youtube/i18n_strings.py:122
|
||||
msgid "Auto (HLS preferred)"
|
||||
msgstr "Auto (HLS preferido)"
|
||||
|
||||
#: youtube/i18n_strings.py:123
|
||||
msgid "Force HLS"
|
||||
msgstr "Forzar HLS"
|
||||
|
||||
#: youtube/i18n_strings.py:124
|
||||
msgid "Force DASH"
|
||||
msgstr "Forzar DASH"
|
||||
|
||||
#: youtube/i18n_strings.py:125
|
||||
msgid "#1"
|
||||
msgstr "#1"
|
||||
|
||||
#: youtube/i18n_strings.py:126
|
||||
msgid "#2"
|
||||
msgstr "#2"
|
||||
|
||||
#: youtube/i18n_strings.py:127
|
||||
msgid "#3"
|
||||
msgstr "#3"
|
||||
|
||||
#: youtube/i18n_strings.py:130 youtube/templates/settings.html:53
|
||||
msgid "Save settings"
|
||||
msgstr "Guardar configuración"
|
||||
|
||||
#: youtube/i18n_strings.py:133
|
||||
msgid "Other"
|
||||
msgstr "Otros"
|
||||
|
||||
#: youtube/i18n_strings.py:136
|
||||
msgid "Playback mode"
|
||||
msgstr "Modo de reproducción"
|
||||
|
||||
#: youtube/i18n_strings.py:139
|
||||
msgid "Autocheck subscriptions"
|
||||
msgstr "Verificar suscripciones automáticamente"
|
||||
|
||||
#: youtube/i18n_strings.py:140
|
||||
msgid "Include shorts in subscriptions"
|
||||
msgstr "Incluir shorts en suscripciones"
|
||||
|
||||
#: youtube/i18n_strings.py:141
|
||||
msgid "Include shorts in channel"
|
||||
msgstr "Incluir shorts en el canal"
|
||||
|
||||
#: youtube/templates/base.html:44
|
||||
msgid "Type to search..."
|
||||
msgstr "Escribe para buscar..."
|
||||
|
||||
#: youtube/templates/comments.html:61
|
||||
msgid "More comments"
|
||||
msgstr "Más comentarios"
|
||||
|
||||
#: youtube/templates/watch.html:100
|
||||
msgid "Direct Link"
|
||||
msgstr "Enlace directo"
|
||||
|
||||
#: youtube/templates/watch.html:152
|
||||
msgid "More info"
|
||||
msgstr "Más información"
|
||||
|
||||
#: youtube/templates/watch.html:176 youtube/templates/watch.html:203
|
||||
msgid "AutoNext"
|
||||
msgstr "Siguiente automático"
|
||||
|
||||
#: youtube/templates/watch.html:225
|
||||
msgid "Related Videos"
|
||||
msgstr "Videos relacionados"
|
||||
|
||||
#: youtube/templates/watch.html:239
|
||||
msgid "Comments disabled"
|
||||
msgstr "Comentarios deshabilitados"
|
||||
|
||||
#: youtube/templates/watch.html:242
|
||||
msgid "Comment"
|
||||
msgstr "Comentario"
|
||||
|
||||
@@ -8,7 +8,7 @@ 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"
|
||||
"POT-Creation-Date: 2026-04-05 16:52-0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,59 +17,415 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
#: youtube/templates/base.html:38
|
||||
msgid "Type to search..."
|
||||
#: youtube/i18n_strings.py:13
|
||||
msgid "Network"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:39
|
||||
msgid "Search"
|
||||
#: youtube/i18n_strings.py:14
|
||||
msgid "Playback"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:45
|
||||
msgid "Options"
|
||||
#: youtube/i18n_strings.py:15
|
||||
msgid "Interface"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:47
|
||||
#: youtube/i18n_strings.py:18
|
||||
msgid "Route Tor"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:19
|
||||
msgid "Default subtitles mode"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:20
|
||||
msgid "AV1 Codec Ranking"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:21
|
||||
msgid "VP8/VP9 Codec Ranking"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:22
|
||||
msgid "H.264 Codec Ranking"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:23
|
||||
msgid "Use integrated sources"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:24
|
||||
msgid "Route images"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:25
|
||||
msgid "Enable comments.js"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:26
|
||||
msgid "Enable SponsorBlock"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:27
|
||||
msgid "Enable embed page"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:30
|
||||
msgid "Related videos mode"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:31
|
||||
msgid "Comments mode"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:32
|
||||
msgid "Enable comment avatars"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:33
|
||||
msgid "Default comment sorting"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:34
|
||||
msgid "Theater mode"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:35
|
||||
msgid "Autoplay videos"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:36
|
||||
msgid "Default resolution"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:37
|
||||
msgid "Use video player"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:38
|
||||
msgid "Use video download"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:39
|
||||
msgid "Proxy images"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:40
|
||||
msgid "Theme"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:41
|
||||
msgid "Font"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:42
|
||||
msgid "Language"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:43
|
||||
msgid "Embed page mode"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:46
|
||||
msgid "Off"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:47
|
||||
msgid "On"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:48
|
||||
msgid "Disabled"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:49
|
||||
msgid "Enabled"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:50
|
||||
msgid "Always shown"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:51
|
||||
msgid "Shown by clicking button"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:52
|
||||
msgid "Native"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:53
|
||||
msgid "Native with hotkeys"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:54
|
||||
msgid "Plyr"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:57
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:58
|
||||
msgid "Gray"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:59
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:62
|
||||
msgid "Browser default"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:63
|
||||
msgid "Liberation Serif"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:64
|
||||
msgid "Arial"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:65
|
||||
msgid "Verdana"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:66
|
||||
msgid "Tahoma"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:69 youtube/templates/base.html:53
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:50
|
||||
#: youtube/i18n_strings.py:70 youtube/templates/base.html:56
|
||||
msgid "Relevance"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:54 youtube/templates/base.html:65
|
||||
#: youtube/i18n_strings.py:71 youtube/templates/base.html:60
|
||||
#: youtube/templates/base.html:71
|
||||
msgid "Upload date"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:58
|
||||
#: youtube/i18n_strings.py:72 youtube/templates/base.html:64
|
||||
msgid "View count"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:62
|
||||
#: youtube/i18n_strings.py:73 youtube/templates/base.html:68
|
||||
msgid "Rating"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:68
|
||||
#: youtube/i18n_strings.py:76 youtube/templates/base.html:74
|
||||
msgid "Any"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:72
|
||||
#: youtube/i18n_strings.py:77 youtube/templates/base.html:78
|
||||
msgid "Last hour"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:76
|
||||
#: youtube/i18n_strings.py:78 youtube/templates/base.html:82
|
||||
msgid "Today"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:80
|
||||
#: youtube/i18n_strings.py:79 youtube/templates/base.html:86
|
||||
msgid "This week"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:84
|
||||
#: youtube/i18n_strings.py:80 youtube/templates/base.html:90
|
||||
msgid "This month"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:88
|
||||
#: youtube/i18n_strings.py:81 youtube/templates/base.html:94
|
||||
msgid "This year"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:84
|
||||
msgid "Type"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:85
|
||||
msgid "Video"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:86
|
||||
msgid "Channel"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:87
|
||||
msgid "Playlist"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:88
|
||||
msgid "Movie"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:89
|
||||
msgid "Show"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:92
|
||||
msgid "Duration"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:93
|
||||
msgid "Short (< 4 minutes)"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:94
|
||||
msgid "Long (> 20 minutes)"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:97 youtube/templates/base.html:45
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:98 youtube/templates/watch.html:104
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:99
|
||||
msgid "Subscribe"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:100
|
||||
msgid "Unsubscribe"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:101
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:102
|
||||
msgid "Export"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:103
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:104
|
||||
msgid "Check"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:105
|
||||
msgid "Mute"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:106
|
||||
msgid "Unmute"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:109 youtube/templates/base.html:51
|
||||
msgid "Options"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:110
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:111
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:112
|
||||
msgid "loading..."
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:115
|
||||
msgid "Top"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:116
|
||||
msgid "Newest"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:117
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:118
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:119
|
||||
msgid "Español"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:122
|
||||
msgid "Auto (HLS preferred)"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:123
|
||||
msgid "Force HLS"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:124
|
||||
msgid "Force DASH"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:125
|
||||
msgid "#1"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:126
|
||||
msgid "#2"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:127
|
||||
msgid "#3"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:130 youtube/templates/settings.html:53
|
||||
msgid "Save settings"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:133
|
||||
msgid "Other"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:136
|
||||
msgid "Playback mode"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:139
|
||||
msgid "Autocheck subscriptions"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:140
|
||||
msgid "Include shorts in subscriptions"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/i18n_strings.py:141
|
||||
msgid "Include shorts in channel"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/base.html:44
|
||||
msgid "Type to search..."
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/comments.html:61
|
||||
msgid "More comments"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/watch.html:100
|
||||
msgid "Direct Link"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/watch.html:152
|
||||
msgid "More info"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/watch.html:176 youtube/templates/watch.html:203
|
||||
msgid "AutoNext"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/watch.html:225
|
||||
msgid "Related Videos"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/watch.html:239
|
||||
msgid "Comments disabled"
|
||||
msgstr ""
|
||||
|
||||
#: youtube/templates/watch.html:242
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
@@ -36,64 +36,41 @@ generic_cookie = (('Cookie', 'VISITOR_INFO1_LIVE=ST1Ti53r4fU'),)
|
||||
# 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):
|
||||
# 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)
|
||||
# Tab-specific protobuf field numbers (from Invidious source)
|
||||
# Each tab uses different field numbers in the protobuf structure:
|
||||
# videos: 110 -> 3 -> 15 -> { 2:{1:UUID}, 4:sort, 8:{1:UUID, 3:sort} }
|
||||
# shorts: 110 -> 3 -> 10 -> { 2:{1:UUID}, 4:sort, 7:{1:UUID, 3:sort} }
|
||||
# streams: 110 -> 3 -> 14 -> { 2:{1:UUID}, 5:sort, 8:{1:UUID, 3:sort} }
|
||||
tab_config = {
|
||||
'videos': {'tab_field': 15, 'sort_field': 4, 'embedded_field': 8},
|
||||
'shorts': {'tab_field': 10, 'sort_field': 4, 'embedded_field': 7},
|
||||
'streams': {'tab_field': 14, 'sort_field': 5, 'embedded_field': 8},
|
||||
}
|
||||
config = tab_config.get(tab, tab_config['videos'])
|
||||
tab_field = config['tab_field']
|
||||
sort_field = config['sort_field']
|
||||
embedded_field = config['embedded_field']
|
||||
|
||||
offset = 30*(int(page) - 1)
|
||||
# Map sort values to YouTube API values
|
||||
if tab == 'streams':
|
||||
sort_mapping = {'1': 14, '2': 13, '3': 12, '4': 12}
|
||||
else:
|
||||
sort_mapping = {'1': 2, '2': 5, '3': 4, '4': 4}
|
||||
new_sort = sort_mapping.get(sort, sort_mapping['3'])
|
||||
|
||||
# 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 (field 1)
|
||||
uuid_str = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
# 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)
|
||||
# Build the tab-level object matching Invidious structure exactly:
|
||||
# { 2: embedded{1: UUID}, sort_field: sort_val, embedded_field: embedded{1: UUID, 3: sort_val} }
|
||||
tab_content = (
|
||||
proto.string(2, proto.string(1, uuid_str))
|
||||
+ proto.uint(sort_field, new_sort)
|
||||
+ proto.string(embedded_field,
|
||||
proto.string(1, uuid_str) + proto.uint(3, new_sort))
|
||||
)
|
||||
|
||||
tab_wrapper = proto.string(tab_field, tab_content)
|
||||
inner_container = proto.string(3, tab_wrapper)
|
||||
outer_container = proto.string(110, inner_container)
|
||||
|
||||
@@ -297,6 +274,8 @@ def get_channel_tab(channel_id, page="1", sort=3, tab='videos', view=1,
|
||||
|
||||
# cache entries expire after 30 minutes
|
||||
number_of_videos_cache = cachetools.TTLCache(128, 30*60)
|
||||
# Cache for continuation tokens (shorts/streams pagination)
|
||||
continuation_token_cache = cachetools.TTLCache(512, 15*60)
|
||||
@cachetools.cached(number_of_videos_cache)
|
||||
def get_number_of_videos_channel(channel_id):
|
||||
if channel_id is None:
|
||||
@@ -346,11 +325,10 @@ def get_channel_id(base_url):
|
||||
metadata_cache = cachetools.LRUCache(128)
|
||||
@cachetools.cached(metadata_cache)
|
||||
def get_metadata(channel_id):
|
||||
base_url = 'https://www.youtube.com/channel/' + channel_id
|
||||
polymer_json = util.fetch_url(base_url + '/about?pbj=1',
|
||||
headers_desktop,
|
||||
debug_name='gen_channel_about',
|
||||
report_text='Retrieved channel metadata')
|
||||
# Use youtubei browse API to get channel metadata
|
||||
polymer_json = util.call_youtube_api('web', 'browse', {
|
||||
'browseId': channel_id,
|
||||
})
|
||||
info = yt_data_extract.extract_channel_info(json.loads(polymer_json),
|
||||
'about',
|
||||
continuation=False)
|
||||
@@ -508,28 +486,64 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None):
|
||||
|
||||
# Use the regular channel API
|
||||
if tab in ('shorts', 'streams') or (tab=='videos' and try_channel_api):
|
||||
if channel_id:
|
||||
num_videos_call = (get_number_of_videos_channel, channel_id)
|
||||
else:
|
||||
num_videos_call = (get_number_of_videos_general, base_url)
|
||||
if not channel_id:
|
||||
channel_id = get_channel_id(base_url)
|
||||
|
||||
# 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)
|
||||
# For shorts/streams, use continuation token from cache or request
|
||||
if tab in ('shorts', 'streams'):
|
||||
if ctoken:
|
||||
# Use ctoken directly from request (passed via pagination)
|
||||
polymer_json = util.call_youtube_api('web', 'browse', {
|
||||
'continuation': ctoken,
|
||||
})
|
||||
continuation = True
|
||||
elif page_number > 1:
|
||||
# For page 2+, get ctoken from cache
|
||||
cache_key = (channel_id, tab, sort, page_number - 1)
|
||||
cached_ctoken = continuation_token_cache.get(cache_key)
|
||||
if cached_ctoken:
|
||||
polymer_json = util.call_youtube_api('web', 'browse', {
|
||||
'continuation': cached_ctoken,
|
||||
})
|
||||
continuation = True
|
||||
else:
|
||||
# Fallback: generate fresh ctoken
|
||||
page_call = (get_channel_tab, channel_id, str(page_number), sort, tab, int(view))
|
||||
continuation = True
|
||||
polymer_json = gevent.spawn(*page_call)
|
||||
polymer_json.join()
|
||||
if polymer_json.exception:
|
||||
raise polymer_json.exception
|
||||
polymer_json = polymer_json.value
|
||||
else:
|
||||
# Page 1: generate fresh ctoken
|
||||
page_call = (get_channel_tab, channel_id, str(page_number), sort, tab, int(view))
|
||||
continuation = True
|
||||
polymer_json = gevent.spawn(*page_call)
|
||||
polymer_json.join()
|
||||
if polymer_json.exception:
|
||||
raise polymer_json.exception
|
||||
polymer_json = polymer_json.value
|
||||
else:
|
||||
# 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.')
|
||||
# videos tab - original logic
|
||||
page_call = (get_channel_tab, channel_id, str(page_number), sort,
|
||||
tab, int(view))
|
||||
continuation = True
|
||||
|
||||
tasks = (
|
||||
gevent.spawn(*num_videos_call),
|
||||
gevent.spawn(*page_call),
|
||||
)
|
||||
gevent.joinall(tasks)
|
||||
util.check_gevent_exceptions(*tasks)
|
||||
number_of_videos, polymer_json = tasks[0].value, tasks[1].value
|
||||
if tab == 'videos':
|
||||
# Only need video count for the videos tab
|
||||
if channel_id:
|
||||
num_videos_call = (get_number_of_videos_channel, channel_id)
|
||||
else:
|
||||
num_videos_call = (get_number_of_videos_general, base_url)
|
||||
tasks = (
|
||||
gevent.spawn(*num_videos_call),
|
||||
gevent.spawn(*page_call),
|
||||
)
|
||||
gevent.joinall(tasks)
|
||||
util.check_gevent_exceptions(*tasks)
|
||||
number_of_videos, polymer_json = tasks[0].value, tasks[1].value
|
||||
# For shorts/streams, polymer_json is already set above, nothing to do here
|
||||
|
||||
elif tab == 'about':
|
||||
# polymer_json = util.fetch_url(base_url + '/about?pbj=1', headers_desktop, debug_name='gen_channel_about')
|
||||
@@ -577,7 +591,8 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None):
|
||||
channel_id = info['channel_id']
|
||||
|
||||
# Will have microformat present, cache metadata while we have it
|
||||
if channel_id and default_params and tab not in ('videos', 'about'):
|
||||
if (channel_id and default_params and tab not in ('videos', 'about')
|
||||
and info.get('channel_name') is not None):
|
||||
metadata = extract_metadata_for_caching(info)
|
||||
set_cached_metadata(channel_id, metadata)
|
||||
# Otherwise, populate with our (hopefully cached) metadata
|
||||
@@ -595,8 +610,16 @@ def get_channel_page_general_url(base_url, tab, request, channel_id=None):
|
||||
item.update(additional_info)
|
||||
|
||||
if tab in ('videos', 'shorts', 'streams'):
|
||||
if tab in ('shorts', 'streams'):
|
||||
# For shorts/streams, use ctoken to determine pagination
|
||||
info['is_last_page'] = (info.get('ctoken') is None)
|
||||
number_of_videos = len(info.get('items', []))
|
||||
# Cache the ctoken for next page
|
||||
if info.get('ctoken'):
|
||||
cache_key = (channel_id, tab, sort, page_number)
|
||||
continuation_token_cache[cache_key] = info['ctoken']
|
||||
info['number_of_videos'] = number_of_videos
|
||||
info['number_of_pages'] = math.ceil(number_of_videos/page_size)
|
||||
info['number_of_pages'] = math.ceil(number_of_videos/page_size) if number_of_videos else 1
|
||||
info['header_playlist_names'] = local_playlist.get_playlist_names()
|
||||
if tab in ('videos', 'shorts', 'streams', 'playlists'):
|
||||
info['current_sort'] = sort
|
||||
|
||||
23
youtube/hls_cache.py
Normal file
23
youtube/hls_cache.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Multi-audio track support via HLS streaming.
|
||||
|
||||
Instead of downloading all segments, we proxy the HLS playlist and
|
||||
let the browser stream the audio directly. Zero local storage needed.
|
||||
"""
|
||||
|
||||
_tracks = {} # cache_key -> {'hls_url': str, ...}
|
||||
|
||||
|
||||
def register_track(cache_key, hls_playlist_url, content_length=0,
|
||||
video_id=None, track_id=None):
|
||||
print(f'[audio-track-cache] Registering track: {cache_key} -> {hls_playlist_url[:80]}...')
|
||||
_tracks[cache_key] = {'hls_url': hls_playlist_url}
|
||||
print(f'[audio-track-cache] Available tracks: {list(_tracks.keys())}')
|
||||
|
||||
|
||||
def get_hls_url(cache_key):
|
||||
entry = _tracks.get(cache_key)
|
||||
if entry:
|
||||
print(f'[audio-track-cache] Found track: {cache_key}')
|
||||
else:
|
||||
print(f'[audio-track-cache] Track not found: {cache_key}')
|
||||
return entry['hls_url'] if entry else None
|
||||
@@ -110,3 +110,32 @@ OPTIONS = _l('Options')
|
||||
SETTINGS = _l('Settings')
|
||||
ERROR = _l('Error')
|
||||
LOADING = _l('loading...')
|
||||
|
||||
# Settings option values
|
||||
TOP = _l('Top')
|
||||
NEWEST = _l('Newest')
|
||||
AUTO = _l('Auto')
|
||||
ENGLISH = _l('English')
|
||||
ESPANOL = _l('Español')
|
||||
|
||||
# Playback options
|
||||
AUTO_HLS_PREFERRED = _l('Auto (HLS preferred)')
|
||||
FORCE_HLS = _l('Force HLS')
|
||||
FORCE_DASH = _l('Force DASH')
|
||||
RANKING_1 = _l('#1')
|
||||
RANKING_2 = _l('#2')
|
||||
RANKING_3 = _l('#3')
|
||||
|
||||
# Form actions
|
||||
SAVE_SETTINGS = _l('Save settings')
|
||||
|
||||
# Other category
|
||||
OTHER = _l('Other')
|
||||
|
||||
# Settings labels
|
||||
PLAYBACK_MODE = _l('Playback mode')
|
||||
|
||||
# Subscription settings (may be used in future)
|
||||
AUTOCHECK_SUBSCRIPTIONS = _l('Autocheck subscriptions')
|
||||
INCLUDE_SHORTS_SUBSCRIPTIONS = _l('Include shorts in subscriptions')
|
||||
INCLUDE_SHORTS_CHANNEL = _l('Include shorts in channel')
|
||||
|
||||
@@ -8,6 +8,7 @@ import html
|
||||
import gevent
|
||||
import urllib
|
||||
import math
|
||||
import glob
|
||||
|
||||
import flask
|
||||
from flask import request
|
||||
@@ -16,21 +17,44 @@ playlists_directory = os.path.join(settings.data_dir, "playlists")
|
||||
thumbnails_directory = os.path.join(settings.data_dir, "playlist_thumbnails")
|
||||
|
||||
|
||||
def _find_playlist_path(name):
|
||||
"""Find playlist file robustly, handling trailing spaces in filenames"""
|
||||
name = name.strip()
|
||||
pattern = os.path.join(playlists_directory, name + "*.txt")
|
||||
files = glob.glob(pattern)
|
||||
return files[0] if files else os.path.join(playlists_directory, name + ".txt")
|
||||
|
||||
|
||||
def _parse_playlist_lines(data):
|
||||
"""Parse playlist data lines robustly, skipping empty/malformed entries"""
|
||||
videos = []
|
||||
for line in data.splitlines():
|
||||
clean_line = line.strip()
|
||||
if not clean_line:
|
||||
continue
|
||||
try:
|
||||
videos.append(json.loads(clean_line))
|
||||
except json.decoder.JSONDecodeError:
|
||||
print('Corrupt playlist entry: ' + clean_line)
|
||||
return videos
|
||||
|
||||
|
||||
def video_ids_in_playlist(name):
|
||||
try:
|
||||
with open(os.path.join(playlists_directory, name + ".txt"), 'r', encoding='utf-8') as file:
|
||||
playlist_path = _find_playlist_path(name)
|
||||
with open(playlist_path, 'r', encoding='utf-8') as file:
|
||||
videos = file.read()
|
||||
return set(json.loads(video)['id'] for video in videos.splitlines())
|
||||
return set(json.loads(line.strip())['id'] for line in videos.splitlines() if line.strip())
|
||||
except FileNotFoundError:
|
||||
return set()
|
||||
|
||||
|
||||
def add_to_playlist(name, video_info_list):
|
||||
if not os.path.exists(playlists_directory):
|
||||
os.makedirs(playlists_directory)
|
||||
os.makedirs(playlists_directory, exist_ok=True)
|
||||
ids = video_ids_in_playlist(name)
|
||||
missing_thumbnails = []
|
||||
with open(os.path.join(playlists_directory, name + ".txt"), "a", encoding='utf-8') as file:
|
||||
playlist_path = _find_playlist_path(name)
|
||||
with open(playlist_path, "a", encoding='utf-8') as file:
|
||||
for info in video_info_list:
|
||||
id = json.loads(info)['id']
|
||||
if id not in ids:
|
||||
@@ -68,20 +92,14 @@ def add_extra_info_to_videos(videos, playlist_name):
|
||||
|
||||
def read_playlist(name):
|
||||
'''Returns a list of videos for the given playlist name'''
|
||||
playlist_path = os.path.join(playlists_directory, name + '.txt')
|
||||
with open(playlist_path, 'r', encoding='utf-8') as f:
|
||||
data = f.read()
|
||||
playlist_path = _find_playlist_path(name)
|
||||
try:
|
||||
with open(playlist_path, 'r', encoding='utf-8') as f:
|
||||
data = f.read()
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
videos = []
|
||||
videos_json = data.splitlines()
|
||||
for video_json in videos_json:
|
||||
try:
|
||||
info = json.loads(video_json)
|
||||
videos.append(info)
|
||||
except json.decoder.JSONDecodeError:
|
||||
if not video_json.strip() == '':
|
||||
print('Corrupt playlist video entry: ' + video_json)
|
||||
return videos
|
||||
return _parse_playlist_lines(data)
|
||||
|
||||
|
||||
def get_local_playlist_videos(name, offset=0, amount=50):
|
||||
@@ -103,14 +121,21 @@ def get_playlist_names():
|
||||
|
||||
def remove_from_playlist(name, video_info_list):
|
||||
ids = [json.loads(video)['id'] for video in video_info_list]
|
||||
with open(os.path.join(playlists_directory, name + ".txt"), 'r', encoding='utf-8') as file:
|
||||
playlist_path = _find_playlist_path(name)
|
||||
with open(playlist_path, 'r', encoding='utf-8') as file:
|
||||
videos = file.read()
|
||||
videos_in = videos.splitlines()
|
||||
videos_out = []
|
||||
for video in videos_in:
|
||||
if json.loads(video)['id'] not in ids:
|
||||
videos_out.append(video)
|
||||
with open(os.path.join(playlists_directory, name + ".txt"), 'w', encoding='utf-8') as file:
|
||||
clean = video.strip()
|
||||
if not clean:
|
||||
continue
|
||||
try:
|
||||
if json.loads(clean)['id'] not in ids:
|
||||
videos_out.append(clean)
|
||||
except json.decoder.JSONDecodeError:
|
||||
pass
|
||||
with open(playlist_path, 'w', encoding='utf-8') as file:
|
||||
file.write("\n".join(videos_out) + "\n")
|
||||
|
||||
try:
|
||||
|
||||
@@ -30,42 +30,58 @@ def playlist_ctoken(playlist_id, offset, include_shorts=True):
|
||||
|
||||
def playlist_first_page(playlist_id, report_text="Retrieved playlist",
|
||||
use_mobile=False):
|
||||
if use_mobile:
|
||||
url = 'https://m.youtube.com/playlist?list=' + playlist_id + '&pbj=1'
|
||||
content = util.fetch_url(
|
||||
url, util.mobile_xhr_headers,
|
||||
report_text=report_text, debug_name='playlist_first_page'
|
||||
)
|
||||
content = json.loads(content.decode('utf-8'))
|
||||
else:
|
||||
url = 'https://www.youtube.com/playlist?list=' + playlist_id + '&pbj=1'
|
||||
content = util.fetch_url(
|
||||
url, util.desktop_xhr_headers,
|
||||
report_text=report_text, debug_name='playlist_first_page'
|
||||
)
|
||||
content = json.loads(content.decode('utf-8'))
|
||||
# Use innertube API (pbj=1 no longer works for many playlists)
|
||||
key = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
|
||||
url = 'https://www.youtube.com/youtubei/v1/browse?key=' + key
|
||||
|
||||
return content
|
||||
data = {
|
||||
'context': {
|
||||
'client': {
|
||||
'hl': 'en',
|
||||
'gl': 'US',
|
||||
'clientName': 'WEB',
|
||||
'clientVersion': '2.20240327.00.00',
|
||||
},
|
||||
},
|
||||
'browseId': 'VL' + playlist_id,
|
||||
}
|
||||
|
||||
content_type_header = (('Content-Type', 'application/json'),)
|
||||
content = util.fetch_url(
|
||||
url, util.desktop_xhr_headers + content_type_header,
|
||||
data=json.dumps(data),
|
||||
report_text=report_text, debug_name='playlist_first_page'
|
||||
)
|
||||
return json.loads(content.decode('utf-8'))
|
||||
|
||||
|
||||
def get_videos(playlist_id, page, include_shorts=True, use_mobile=False,
|
||||
report_text='Retrieved playlist'):
|
||||
# mobile requests return 20 videos per page
|
||||
if use_mobile:
|
||||
page_size = 20
|
||||
headers = util.mobile_xhr_headers
|
||||
# desktop requests return 100 videos per page
|
||||
else:
|
||||
page_size = 100
|
||||
headers = util.desktop_xhr_headers
|
||||
page_size = 100
|
||||
|
||||
url = "https://m.youtube.com/playlist?ctoken="
|
||||
url += playlist_ctoken(playlist_id, (int(page)-1)*page_size,
|
||||
include_shorts=include_shorts)
|
||||
url += "&pbj=1"
|
||||
key = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
|
||||
url = 'https://www.youtube.com/youtubei/v1/browse?key=' + key
|
||||
|
||||
ctoken = playlist_ctoken(playlist_id, (int(page)-1)*page_size,
|
||||
include_shorts=include_shorts)
|
||||
|
||||
data = {
|
||||
'context': {
|
||||
'client': {
|
||||
'hl': 'en',
|
||||
'gl': 'US',
|
||||
'clientName': 'WEB',
|
||||
'clientVersion': '2.20240327.00.00',
|
||||
},
|
||||
},
|
||||
'continuation': ctoken,
|
||||
}
|
||||
|
||||
content_type_header = (('Content-Type', 'application/json'),)
|
||||
content = util.fetch_url(
|
||||
url, headers, report_text=report_text,
|
||||
debug_name='playlist_videos'
|
||||
url, util.desktop_xhr_headers + content_type_header,
|
||||
data=json.dumps(data),
|
||||
report_text=report_text, debug_name='playlist_videos'
|
||||
)
|
||||
|
||||
info = json.loads(content.decode('utf-8'))
|
||||
@@ -96,7 +112,7 @@ def get_playlist_page():
|
||||
tasks = (
|
||||
gevent.spawn(
|
||||
playlist_first_page, playlist_id,
|
||||
report_text="Retrieved playlist info", use_mobile=True
|
||||
report_text="Retrieved playlist info"
|
||||
),
|
||||
gevent.spawn(get_videos, playlist_id, page)
|
||||
)
|
||||
|
||||
2
youtube/static/js/hls.min.js
vendored
Normal file
2
youtube/static/js/hls.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
youtube/static/js/hls.min.js.map
Normal file
1
youtube/static/js/hls.min.js.map
Normal file
File diff suppressed because one or more lines are too long
@@ -13,10 +13,12 @@
|
||||
let qualityOptions = [];
|
||||
let qualityDefault;
|
||||
|
||||
// Collect uni sources (integrated)
|
||||
for (let src of data.uni_sources) {
|
||||
qualityOptions.push(src.quality_string);
|
||||
}
|
||||
|
||||
// Collect pair sources (av-merge)
|
||||
for (let src of data.pair_sources) {
|
||||
qualityOptions.push(src.quality_string);
|
||||
}
|
||||
@@ -29,6 +31,37 @@
|
||||
qualityDefault = 'None';
|
||||
}
|
||||
|
||||
// Current av-merge instance
|
||||
let avMerge = null;
|
||||
|
||||
// Change quality: handles both uni (integrated) and pair (av-merge)
|
||||
function changeQuality(selection) {
|
||||
let currentVideoTime = video.currentTime;
|
||||
let videoPaused = video.paused;
|
||||
let videoSpeed = video.playbackRate;
|
||||
let srcInfo;
|
||||
|
||||
// Close previous av-merge if any
|
||||
if (avMerge && typeof avMerge.close === 'function') {
|
||||
avMerge.close();
|
||||
}
|
||||
|
||||
if (selection.type == 'uni') {
|
||||
srcInfo = data.uni_sources[selection.index];
|
||||
video.src = srcInfo.url;
|
||||
avMerge = null;
|
||||
} else {
|
||||
srcInfo = data.pair_sources[selection.index];
|
||||
avMerge = new AVMerge(video, srcInfo, currentVideoTime);
|
||||
}
|
||||
|
||||
video.currentTime = currentVideoTime;
|
||||
if (!videoPaused) {
|
||||
video.play();
|
||||
}
|
||||
video.playbackRate = videoSpeed;
|
||||
}
|
||||
|
||||
// Fix plyr refusing to work with qualities that are strings
|
||||
Object.defineProperty(Plyr.prototype, 'quality', {
|
||||
set: function (input) {
|
||||
@@ -59,7 +92,6 @@
|
||||
});
|
||||
|
||||
const playerOptions = {
|
||||
// Learning about autoplay permission https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy/autoplay#syntax
|
||||
autoplay: autoplayActive,
|
||||
disableContextMenu: false,
|
||||
captions: {
|
||||
@@ -92,6 +124,7 @@
|
||||
if (quality == 'None') {
|
||||
return;
|
||||
}
|
||||
// Check if it's a uni source (integrated)
|
||||
if (quality.includes('(integrated)')) {
|
||||
for (let i = 0; i < data.uni_sources.length; i++) {
|
||||
if (data.uni_sources[i].quality_string == quality) {
|
||||
@@ -100,6 +133,7 @@
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's a pair source (av-merge)
|
||||
for (let i = 0; i < data.pair_sources.length; i++) {
|
||||
if (data.pair_sources[i].quality_string == quality) {
|
||||
changeQuality({ type: 'pair', index: i });
|
||||
@@ -117,20 +151,30 @@
|
||||
tooltips: {
|
||||
controls: true,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const player = new Plyr(document.getElementById('js-video-player'), playerOptions);
|
||||
const video = document.getElementById('js-video-player');
|
||||
const player = new Plyr(video, playerOptions);
|
||||
|
||||
// Hide audio track selector (DASH doesn't support multi-audio)
|
||||
const audioContainer = document.getElementById('plyr-audio-container');
|
||||
if (audioContainer) audioContainer.style.display = 'none';
|
||||
|
||||
// disable double click to fullscreen
|
||||
// https://github.com/sampotts/plyr/issues/1370#issuecomment-528966795
|
||||
player.eventListeners.forEach(function(eventListener) {
|
||||
if(eventListener.type === 'dblclick') {
|
||||
eventListener.element.removeEventListener(eventListener.type, eventListener.callback, eventListener.options);
|
||||
}
|
||||
});
|
||||
|
||||
// Add .started property, true after the playback has been started
|
||||
// Needed so controls won't be hidden before playback has started
|
||||
// Add .started property
|
||||
player.started = false;
|
||||
player.once('playing', function(){this.started = true});
|
||||
player.once('playing', function(){ this.started = true; });
|
||||
|
||||
// Set initial time
|
||||
if (data.time_start != 0) {
|
||||
video.addEventListener('loadedmetadata', function() {
|
||||
video.currentTime = data.time_start;
|
||||
});
|
||||
}
|
||||
})();
|
||||
538
youtube/static/js/plyr.hls.start.js
Normal file
538
youtube/static/js/plyr.hls.start.js
Normal file
@@ -0,0 +1,538 @@
|
||||
(function main() {
|
||||
'use strict';
|
||||
|
||||
console.log('Plyr start script loaded');
|
||||
|
||||
// Captions
|
||||
let captionsActive = false;
|
||||
if (typeof data !== 'undefined' && (data.settings.subtitles_mode === 2 || (data.settings.subtitles_mode === 1 && data.has_manual_captions))) {
|
||||
captionsActive = true;
|
||||
}
|
||||
|
||||
// AutoPlay
|
||||
let autoplayActive = typeof data !== 'undefined' && data.settings.autoplay_videos || false;
|
||||
|
||||
// Quality map: label -> hls level index
|
||||
window.hlsQualityMap = {};
|
||||
|
||||
let plyrInstance = null;
|
||||
let currentQuality = 'auto';
|
||||
let hls = null;
|
||||
window.hls = null;
|
||||
|
||||
/**
|
||||
* Get start level from settings (highest quality <= target)
|
||||
*/
|
||||
function getStartLevel(levels) {
|
||||
if (typeof data === 'undefined' || !data.settings) return -1;
|
||||
const defaultRes = data.settings.default_resolution;
|
||||
if (defaultRes === 'auto' || !defaultRes) return -1;
|
||||
const target = parseInt(defaultRes);
|
||||
|
||||
// Find the level with the highest height that is still <= target
|
||||
let bestLevel = -1;
|
||||
let bestHeight = 0;
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
const h = levels[i].height;
|
||||
if (h <= target && h > bestHeight) {
|
||||
bestHeight = h;
|
||||
bestLevel = i;
|
||||
}
|
||||
}
|
||||
return bestLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize HLS
|
||||
*/
|
||||
function initHLS(manifestUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!manifestUrl) {
|
||||
reject('No HLS manifest URL provided');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Initializing HLS for Plyr:', manifestUrl);
|
||||
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
}
|
||||
|
||||
hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
startLevel: -1,
|
||||
});
|
||||
|
||||
window.hls = hls;
|
||||
|
||||
const video = document.getElementById('js-video-player');
|
||||
if (!video) {
|
||||
reject('Video element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
hls.loadSource(manifestUrl);
|
||||
hls.attachMedia(video);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) {
|
||||
console.log('HLS manifest parsed, levels:', hls.levels?.length);
|
||||
|
||||
// Set initial quality from settings
|
||||
const startLevel = getStartLevel(hls.levels);
|
||||
if (startLevel !== -1) {
|
||||
hls.currentLevel = startLevel;
|
||||
const level = hls.levels[startLevel];
|
||||
currentQuality = level.height + 'p';
|
||||
console.log('Starting at resolution:', currentQuality);
|
||||
}
|
||||
|
||||
resolve(hls);
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, function(_, data) {
|
||||
if (data.fatal) {
|
||||
console.error('HLS fatal error:', data.type, data.details);
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
reject(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change HLS quality
|
||||
*/
|
||||
function changeHLSQuality(quality) {
|
||||
if (!hls) {
|
||||
console.error('HLS not available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Changing HLS quality to:', quality);
|
||||
|
||||
if (quality === 'auto') {
|
||||
hls.currentLevel = -1;
|
||||
currentQuality = 'auto';
|
||||
console.log('HLS quality set to Auto');
|
||||
const qualityBtnText = document.getElementById('plyr-quality-text');
|
||||
if (qualityBtnText) {
|
||||
qualityBtnText.textContent = 'Auto';
|
||||
}
|
||||
} else {
|
||||
const levelIndex = window.hlsQualityMap[quality];
|
||||
if (levelIndex !== undefined) {
|
||||
hls.currentLevel = levelIndex;
|
||||
currentQuality = quality;
|
||||
console.log('HLS quality set to:', quality);
|
||||
|
||||
const qualityBtnText = document.getElementById('plyr-quality-text');
|
||||
if (qualityBtnText) {
|
||||
qualityBtnText.textContent = quality;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom quality control in Plyr controls
|
||||
*/
|
||||
function addCustomQualityControl(player, qualityLabels) {
|
||||
player.on('ready', () => {
|
||||
console.log('Adding custom quality control...');
|
||||
|
||||
const controls = player.elements.container.querySelector('.plyr__controls');
|
||||
if (!controls) {
|
||||
console.error('Controls not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.getElementById('plyr-quality-container')) {
|
||||
console.log('Quality control already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
const qualityContainer = document.createElement('div');
|
||||
qualityContainer.id = 'plyr-quality-container';
|
||||
qualityContainer.className = 'plyr__control plyr__control--custom';
|
||||
|
||||
const qualityButton = document.createElement('button');
|
||||
qualityButton.type = 'button';
|
||||
qualityButton.className = 'plyr__control';
|
||||
qualityButton.setAttribute('data-plyr', 'quality-custom');
|
||||
qualityButton.setAttribute('aria-label', 'Quality');
|
||||
qualityButton.innerHTML = `
|
||||
<svg class="plyr__icon hls_quality_icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="12" x2="16" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12" y2="16"></line>
|
||||
</svg>
|
||||
<span id="plyr-quality-text">${currentQuality === 'auto' ? 'Auto' : currentQuality}</span>
|
||||
<svg class="plyr__icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'plyr-quality-dropdown';
|
||||
|
||||
qualityLabels.forEach(label => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'plyr-quality-option';
|
||||
option.textContent = label === 'auto' ? 'Auto' : label;
|
||||
|
||||
if (label === currentQuality) {
|
||||
option.setAttribute('data-active', 'true');
|
||||
}
|
||||
|
||||
option.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
changeHLSQuality(label);
|
||||
|
||||
dropdown.querySelectorAll('.plyr-quality-option').forEach(opt => {
|
||||
opt.removeAttribute('data-active');
|
||||
});
|
||||
option.setAttribute('data-active', 'true');
|
||||
|
||||
dropdown.style.display = 'none';
|
||||
});
|
||||
|
||||
dropdown.appendChild(option);
|
||||
});
|
||||
|
||||
qualityButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isVisible = dropdown.style.display === 'block';
|
||||
document.querySelectorAll('.plyr-quality-dropdown, .plyr-audio-dropdown').forEach(d => {
|
||||
d.style.display = 'none';
|
||||
});
|
||||
dropdown.style.display = isVisible ? 'none' : 'block';
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!qualityContainer.contains(e.target)) {
|
||||
dropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
qualityContainer.appendChild(qualityButton);
|
||||
qualityContainer.appendChild(dropdown);
|
||||
|
||||
const settingsBtn = controls.querySelector('[data-plyr="settings"]');
|
||||
if (settingsBtn) {
|
||||
settingsBtn.insertAdjacentElement('beforebegin', qualityContainer);
|
||||
} else {
|
||||
controls.appendChild(qualityContainer);
|
||||
}
|
||||
|
||||
console.log('Custom quality control added');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom audio tracks control in Plyr controls
|
||||
*/
|
||||
function addCustomAudioTracksControl(player, hlsInstance) {
|
||||
player.on('ready', () => {
|
||||
console.log('Adding custom audio tracks control...');
|
||||
|
||||
const controls = player.elements.container.querySelector('.plyr__controls');
|
||||
if (!controls) {
|
||||
console.error('Controls not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.getElementById('plyr-audio-container')) {
|
||||
console.log('Audio tracks control already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
const audioContainer = document.createElement('div');
|
||||
audioContainer.id = 'plyr-audio-container';
|
||||
audioContainer.className = 'plyr__control plyr__control--custom';
|
||||
|
||||
const audioButton = document.createElement('button');
|
||||
audioButton.type = 'button';
|
||||
audioButton.className = 'plyr__control';
|
||||
audioButton.setAttribute('data-plyr', 'audio-custom');
|
||||
audioButton.setAttribute('aria-label', 'Audio Track');
|
||||
audioButton.innerHTML = `
|
||||
<svg class="plyr__icon hls_audio_icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 18v-6a9 9 0 0 1 18 0v6"></path>
|
||||
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3z"></path>
|
||||
<path d="M3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"></path>
|
||||
</svg>
|
||||
<span id="plyr-audio-text">Audio</span>
|
||||
<svg class="plyr__icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const audioDropdown = document.createElement('div');
|
||||
audioDropdown.className = 'plyr-audio-dropdown';
|
||||
|
||||
function updateAudioDropdown() {
|
||||
if (!hlsInstance || !hlsInstance.audioTracks) return;
|
||||
|
||||
audioDropdown.innerHTML = '';
|
||||
|
||||
if (hlsInstance.audioTracks.length === 0) {
|
||||
const noTrackMsg = document.createElement('div');
|
||||
noTrackMsg.className = 'plyr-audio-no-tracks';
|
||||
noTrackMsg.textContent = 'No audio tracks';
|
||||
audioDropdown.appendChild(noTrackMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
hlsInstance.audioTracks.forEach((track, idx) => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'plyr-audio-option';
|
||||
option.textContent = track.name || track.lang || `Track ${idx + 1}`;
|
||||
|
||||
if (hlsInstance.audioTrack === idx) {
|
||||
option.setAttribute('data-active', 'true');
|
||||
}
|
||||
|
||||
option.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
hlsInstance.audioTrack = idx;
|
||||
console.log('Audio track changed to:', track.name || track.lang || idx);
|
||||
|
||||
const audioText = document.getElementById('plyr-audio-text');
|
||||
if (audioText) {
|
||||
const trackName = track.name || track.lang || `Track ${idx + 1}`;
|
||||
audioText.textContent = trackName.length > 8 ? trackName.substring(0, 6) + '...' : trackName;
|
||||
}
|
||||
|
||||
audioDropdown.querySelectorAll('.plyr-audio-option').forEach(opt => {
|
||||
opt.removeAttribute('data-active');
|
||||
});
|
||||
option.setAttribute('data-active', 'true');
|
||||
|
||||
audioDropdown.style.display = 'none';
|
||||
});
|
||||
|
||||
audioDropdown.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
audioButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
updateAudioDropdown();
|
||||
const isVisible = audioDropdown.style.display === 'block';
|
||||
document.querySelectorAll('.plyr-quality-dropdown, .plyr-audio-dropdown').forEach(d => {
|
||||
d.style.display = 'none';
|
||||
});
|
||||
audioDropdown.style.display = isVisible ? 'none' : 'block';
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!audioContainer.contains(e.target)) {
|
||||
audioDropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
audioContainer.appendChild(audioButton);
|
||||
audioContainer.appendChild(audioDropdown);
|
||||
|
||||
const qualityContainer = document.getElementById('plyr-quality-container');
|
||||
if (qualityContainer) {
|
||||
qualityContainer.insertAdjacentElement('beforebegin', audioContainer);
|
||||
} else {
|
||||
const settingsBtn = controls.querySelector('[data-plyr="settings"]');
|
||||
if (settingsBtn) {
|
||||
settingsBtn.insertAdjacentElement('beforebegin', audioContainer);
|
||||
} else {
|
||||
controls.appendChild(audioContainer);
|
||||
}
|
||||
}
|
||||
|
||||
if (hlsInstance && hlsInstance.audioTracks && hlsInstance.audioTracks.length > 0) {
|
||||
// Prefer "original" audio track
|
||||
const originalIdx = hlsInstance.audioTracks.findIndex(t => {
|
||||
const name = (t.name || '').toLowerCase();
|
||||
const lang = (t.lang || '').toLowerCase();
|
||||
return name.includes('original') || lang === 'original';
|
||||
});
|
||||
if (originalIdx !== -1) {
|
||||
hlsInstance.audioTrack = originalIdx;
|
||||
console.log('Selected original audio track:', hlsInstance.audioTracks[originalIdx].name);
|
||||
}
|
||||
|
||||
const currentTrack = hlsInstance.audioTracks[hlsInstance.audioTrack];
|
||||
if (currentTrack) {
|
||||
const audioText = document.getElementById('plyr-audio-text');
|
||||
if (audioText) {
|
||||
const trackName = currentTrack.name || currentTrack.lang || 'Audio';
|
||||
audioText.textContent = trackName.length > 8 ? trackName.substring(0, 6) + '...' : trackName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hlsInstance.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
|
||||
console.log('Audio tracks updated, count:', hlsInstance.audioTracks?.length);
|
||||
if (hlsInstance.audioTracks?.length > 0) {
|
||||
updateAudioDropdown();
|
||||
const currentTrack = hlsInstance.audioTracks[hlsInstance.audioTrack];
|
||||
if (currentTrack) {
|
||||
const audioText = document.getElementById('plyr-audio-text');
|
||||
if (audioText) {
|
||||
const trackName = currentTrack.name || currentTrack.lang || 'Audio';
|
||||
audioText.textContent = trackName.length > 8 ? trackName.substring(0, 6) + '...' : trackName;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Custom audio tracks control added');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Plyr with HLS quality options
|
||||
*/
|
||||
function initPlyrWithQuality(hlsInstance) {
|
||||
const video = document.getElementById('js-video-player');
|
||||
|
||||
if (!hlsInstance || !hlsInstance.levels || hlsInstance.levels.length === 0) {
|
||||
console.error('HLS not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!video) {
|
||||
console.error('Video element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('HLS levels available:', hlsInstance.levels.length);
|
||||
|
||||
const sortedLevels = [...hlsInstance.levels].sort((a, b) => b.height - a.height);
|
||||
|
||||
const seenHeights = new Set();
|
||||
const uniqueLevels = [];
|
||||
|
||||
sortedLevels.forEach((level) => {
|
||||
if (!seenHeights.has(level.height)) {
|
||||
seenHeights.add(level.height);
|
||||
uniqueLevels.push(level);
|
||||
}
|
||||
});
|
||||
|
||||
const qualityLabels = ['auto'];
|
||||
uniqueLevels.forEach((level) => {
|
||||
const originalIndex = hlsInstance.levels.indexOf(level);
|
||||
const label = level.height + 'p';
|
||||
if (!window.hlsQualityMap[label]) {
|
||||
qualityLabels.push(label);
|
||||
window.hlsQualityMap[label] = originalIndex;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Quality labels:', qualityLabels);
|
||||
|
||||
const playerOptions = {
|
||||
autoplay: autoplayActive,
|
||||
disableContextMenu: false,
|
||||
captions: {
|
||||
active: captionsActive,
|
||||
language: typeof data !== 'undefined' ? data.settings.subtitles_language : 'en',
|
||||
},
|
||||
controls: [
|
||||
'play-large',
|
||||
'play',
|
||||
'progress',
|
||||
'current-time',
|
||||
'duration',
|
||||
'mute',
|
||||
'volume',
|
||||
'captions',
|
||||
'settings',
|
||||
'pip',
|
||||
'airplay',
|
||||
'fullscreen',
|
||||
],
|
||||
iconUrl: '/youtube.com/static/modules/plyr/plyr.svg',
|
||||
blankVideo: '/youtube.com/static/modules/plyr/blank.webm',
|
||||
debug: false,
|
||||
storage: { enabled: false },
|
||||
previewThumbnails: {
|
||||
enabled: typeof storyboard_url !== 'undefined' && storyboard_url !== null,
|
||||
src: typeof storyboard_url !== 'undefined' && storyboard_url !== null ? [storyboard_url] : [],
|
||||
},
|
||||
settings: ['captions', 'speed', 'loop'],
|
||||
tooltips: {
|
||||
controls: true,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('Creating Plyr...');
|
||||
|
||||
try {
|
||||
plyrInstance = new Plyr(video, playerOptions);
|
||||
console.log('Plyr instance created');
|
||||
|
||||
window.plyrInstance = plyrInstance;
|
||||
|
||||
addCustomQualityControl(plyrInstance, qualityLabels);
|
||||
addCustomAudioTracksControl(plyrInstance, hlsInstance);
|
||||
|
||||
if (plyrInstance.eventListeners) {
|
||||
plyrInstance.eventListeners.forEach(function(eventListener) {
|
||||
if(eventListener.type === 'dblclick') {
|
||||
eventListener.element.removeEventListener(eventListener.type, eventListener.callback, eventListener.options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
plyrInstance.started = false;
|
||||
plyrInstance.once('playing', function(){this.started = true});
|
||||
|
||||
if (typeof data !== 'undefined' && data.time_start != 0) {
|
||||
video.addEventListener('loadedmetadata', function() {
|
||||
video.currentTime = data.time_start;
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Plyr init complete');
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize Plyr:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main initialization
|
||||
*/
|
||||
async function start() {
|
||||
console.log('Starting Plyr with HLS...');
|
||||
|
||||
if (typeof hls_manifest_url === 'undefined' || !hls_manifest_url) {
|
||||
console.error('No HLS manifest URL available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const hlsInstance = await initHLS(hls_manifest_url);
|
||||
initPlyrWithQuality(hlsInstance);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
375
youtube/static/js/storyboard-preview.js
Normal file
375
youtube/static/js/storyboard-preview.js
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* YouTube Storyboard Preview Thumbnails
|
||||
* Shows preview thumbnails when hovering over the progress bar
|
||||
* Works with native HTML5 video player
|
||||
*
|
||||
* Fetches the proxied WebVTT storyboard from backend and extracts image URLs
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
console.log('Storyboard Preview Thumbnails loaded');
|
||||
|
||||
// Storyboard configuration
|
||||
let storyboardImages = []; // Array of {time, imageUrl, x, y, width, height}
|
||||
let previewElement = null;
|
||||
let tooltipElement = null;
|
||||
let video = null;
|
||||
let progressBarRect = null;
|
||||
|
||||
/**
|
||||
* Fetch and parse the storyboard VTT file
|
||||
* The backend generates a VTT with proxied image URLs
|
||||
*/
|
||||
function fetchStoryboardVTT(vttUrl) {
|
||||
return fetch(vttUrl)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Failed to fetch storyboard VTT');
|
||||
return response.text();
|
||||
})
|
||||
.then(vttText => {
|
||||
console.log('Fetched storyboard VTT, length:', vttText.length);
|
||||
|
||||
const lines = vttText.split('\n');
|
||||
const images = [];
|
||||
let currentEntry = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// Parse timestamp line: 00:00:00.000 --> 00:00:10.000
|
||||
if (line.includes('-->')) {
|
||||
const timeMatch = line.match(/^(\d{2}):(\d{2}):(\d{2})\.(\d{3})/);
|
||||
if (timeMatch) {
|
||||
const hours = parseInt(timeMatch[1]);
|
||||
const minutes = parseInt(timeMatch[2]);
|
||||
const seconds = parseInt(timeMatch[3]);
|
||||
const ms = parseInt(timeMatch[4]);
|
||||
currentEntry = {
|
||||
time: hours * 3600 + minutes * 60 + seconds + ms / 1000
|
||||
};
|
||||
}
|
||||
}
|
||||
// Parse image URL with crop parameters: /url#xywh=x,y,w,h
|
||||
else if (line.includes('#xywh=') && currentEntry) {
|
||||
const [urlPart, paramsPart] = line.split('#xywh=');
|
||||
const [x, y, width, height] = paramsPart.split(',').map(Number);
|
||||
|
||||
currentEntry.imageUrl = urlPart;
|
||||
currentEntry.x = x;
|
||||
currentEntry.y = y;
|
||||
currentEntry.width = width;
|
||||
currentEntry.height = height;
|
||||
|
||||
images.push(currentEntry);
|
||||
currentEntry = null;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Parsed', images.length, 'storyboard frames');
|
||||
return images;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time as MM:SS or H:MM:SS
|
||||
*/
|
||||
function formatTime(seconds) {
|
||||
if (isNaN(seconds)) return '0:00';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest storyboard frame for a given time
|
||||
*/
|
||||
function findFrameAtTime(time) {
|
||||
if (!storyboardImages.length) return null;
|
||||
|
||||
// Binary search for efficiency
|
||||
let left = 0;
|
||||
let right = storyboardImages.length - 1;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const frame = storyboardImages[mid];
|
||||
|
||||
if (time >= frame.time && time < (storyboardImages[mid + 1]?.time || Infinity)) {
|
||||
return frame;
|
||||
} else if (time < frame.time) {
|
||||
right = mid - 1;
|
||||
} else {
|
||||
left = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Return closest frame
|
||||
return storyboardImages[Math.min(left, storyboardImages.length - 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect browser
|
||||
*/
|
||||
function getBrowser() {
|
||||
const ua = navigator.userAgent;
|
||||
if (ua.indexOf('Firefox') > -1) return 'firefox';
|
||||
if (ua.indexOf('Chrome') > -1) return 'chrome';
|
||||
if (ua.indexOf('Safari') > -1) return 'safari';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the progress bar position in native video element
|
||||
* Different browsers have different control layouts
|
||||
*/
|
||||
function detectProgressBar() {
|
||||
if (!video) return null;
|
||||
|
||||
const rect = video.getBoundingClientRect();
|
||||
const browser = getBrowser();
|
||||
|
||||
let progressBarArea;
|
||||
|
||||
switch(browser) {
|
||||
case 'firefox':
|
||||
// Firefox: La barra de progreso está en la parte inferior pero más delgada
|
||||
// Normalmente ocupa solo unos 20-25px de altura y está centrada
|
||||
progressBarArea = {
|
||||
top: rect.bottom - 30, // Área más pequeña para Firefox
|
||||
bottom: rect.bottom - 5, // Dejamos espacio para otros controles
|
||||
left: rect.left + 60, // Firefox tiene botones a la izquierda (play, volumen)
|
||||
right: rect.right - 10, // Y a la derecha (fullscreen, etc)
|
||||
height: 25
|
||||
};
|
||||
break;
|
||||
|
||||
case 'chrome':
|
||||
default:
|
||||
// Chrome: La barra de progreso ocupa un área más grande
|
||||
progressBarArea = {
|
||||
top: rect.bottom - 50,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
height: 50
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return progressBarArea;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mouse is over the progress bar area
|
||||
*/
|
||||
function isOverProgressBar(mouseX, mouseY) {
|
||||
if (!progressBarRect) return false;
|
||||
|
||||
return mouseX >= progressBarRect.left &&
|
||||
mouseX <= progressBarRect.right &&
|
||||
mouseY >= progressBarRect.top &&
|
||||
mouseY <= progressBarRect.bottom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize preview elements
|
||||
*/
|
||||
function initPreviewElements() {
|
||||
video = document.getElementById('js-video-player');
|
||||
if (!video) {
|
||||
console.error('Video element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Video element found, browser:', getBrowser());
|
||||
|
||||
// Create preview element
|
||||
previewElement = document.createElement('div');
|
||||
previewElement.className = 'storyboard-preview';
|
||||
previewElement.style.cssText = `
|
||||
position: fixed;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
background: #000;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||||
`;
|
||||
|
||||
// Create tooltip element
|
||||
tooltipElement = document.createElement('div');
|
||||
tooltipElement.className = 'storyboard-tooltip';
|
||||
tooltipElement.style.cssText = `
|
||||
position: absolute;
|
||||
bottom: -25px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-family: Arial, sans-serif;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
previewElement.appendChild(tooltipElement);
|
||||
document.body.appendChild(previewElement);
|
||||
|
||||
// Update progress bar position on mouse move
|
||||
video.addEventListener('mousemove', updateProgressBarPosition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress bar position detection
|
||||
*/
|
||||
function updateProgressBarPosition() {
|
||||
progressBarRect = detectProgressBar();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse move - only show preview when over progress bar area
|
||||
*/
|
||||
function handleMouseMove(e) {
|
||||
if (!video || !storyboardImages.length) return;
|
||||
|
||||
// Update progress bar position on each move
|
||||
progressBarRect = detectProgressBar();
|
||||
|
||||
// Only show preview if mouse is over the progress bar area
|
||||
if (!isOverProgressBar(e.clientX, e.clientY)) {
|
||||
if (previewElement) previewElement.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate position within the progress bar
|
||||
const progressBarWidth = progressBarRect.right - progressBarRect.left;
|
||||
let xInProgressBar = e.clientX - progressBarRect.left;
|
||||
|
||||
// Adjust for Firefox's left offset
|
||||
const browser = getBrowser();
|
||||
if (browser === 'firefox') {
|
||||
// Ajustar el rango para que coincida mejor con la barra real
|
||||
xInProgressBar = Math.max(0, Math.min(xInProgressBar, progressBarWidth));
|
||||
}
|
||||
|
||||
const percentage = Math.max(0, Math.min(1, xInProgressBar / progressBarWidth));
|
||||
const time = percentage * video.duration;
|
||||
const frame = findFrameAtTime(time);
|
||||
|
||||
if (!frame) return;
|
||||
|
||||
// Preview dimensions
|
||||
const previewWidth = 160;
|
||||
const previewHeight = 90;
|
||||
const offsetFromCursor = 10;
|
||||
|
||||
// Position above the cursor
|
||||
let previewTop = e.clientY - previewHeight - offsetFromCursor;
|
||||
|
||||
// If preview would go above the video, position below the cursor
|
||||
const videoRect = video.getBoundingClientRect();
|
||||
if (previewTop < videoRect.top) {
|
||||
previewTop = e.clientY + offsetFromCursor;
|
||||
}
|
||||
|
||||
// Keep preview within horizontal bounds
|
||||
let left = e.clientX - (previewWidth / 2);
|
||||
|
||||
// Ajustes específicos para Firefox
|
||||
if (browser === 'firefox') {
|
||||
// En Firefox, la barra no llega hasta los extremos
|
||||
const minLeft = progressBarRect.left + 10;
|
||||
const maxLeft = progressBarRect.right - previewWidth - 10;
|
||||
left = Math.max(minLeft, Math.min(left, maxLeft));
|
||||
} else {
|
||||
left = Math.max(videoRect.left, Math.min(left, videoRect.right - previewWidth));
|
||||
}
|
||||
|
||||
// Apply all styles
|
||||
previewElement.style.cssText = `
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: ${left}px;
|
||||
top: ${previewTop}px;
|
||||
width: ${previewWidth}px;
|
||||
height: ${previewHeight}px;
|
||||
background-image: url('${frame.imageUrl}');
|
||||
background-position: -${frame.x}px -${frame.y}px;
|
||||
background-size: auto;
|
||||
background-repeat: no-repeat;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
tooltipElement.textContent = formatTime(time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse leave video
|
||||
*/
|
||||
function handleMouseLeave() {
|
||||
if (previewElement) {
|
||||
previewElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize storyboard preview
|
||||
*/
|
||||
function init() {
|
||||
console.log('Initializing storyboard preview...');
|
||||
|
||||
// Check if storyboard URL is available
|
||||
if (typeof storyboard_url === 'undefined' || !storyboard_url) {
|
||||
console.log('No storyboard URL available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Storyboard URL:', storyboard_url);
|
||||
|
||||
// Fetch the proxied VTT file from backend
|
||||
fetchStoryboardVTT(storyboard_url)
|
||||
.then(images => {
|
||||
storyboardImages = images;
|
||||
console.log('Loaded', images.length, 'storyboard images');
|
||||
|
||||
if (images.length === 0) {
|
||||
console.log('No storyboard images parsed');
|
||||
return;
|
||||
}
|
||||
|
||||
initPreviewElements();
|
||||
|
||||
// Add event listeners to video
|
||||
video.addEventListener('mousemove', handleMouseMove);
|
||||
video.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
console.log('Storyboard preview initialized for', getBrowser());
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to load storyboard:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -95,7 +95,11 @@ if (data.playlist && data.playlist['id'] !== null) {
|
||||
|
||||
|
||||
// Autoplay
|
||||
if (data.settings.related_videos_mode !== 0 || data.playlist !== null) {
|
||||
(function() {
|
||||
if (data.settings.related_videos_mode === 0 && data.playlist === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let playability_error = !!data.playability_error;
|
||||
let isPlaylist = false;
|
||||
if (data.playlist !== null && data.playlist['current_index'] !== null)
|
||||
@@ -155,7 +159,10 @@ if (data.settings.related_videos_mode !== 0 || data.playlist !== null) {
|
||||
if(!playability_error){
|
||||
// play the video if autoplay is on
|
||||
if(autoplayEnabled){
|
||||
video.play();
|
||||
video.play().catch(function(e) {
|
||||
// Autoplay blocked by browser - ignore silently
|
||||
console.log('Autoplay blocked:', e.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,4 +204,4 @@ if (data.settings.related_videos_mode !== 0 || data.playlist !== null) {
|
||||
window.setTimeout(nextVideo, nextVideoDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
329
youtube/static/js/watch.hls.js
Normal file
329
youtube/static/js/watch.hls.js
Normal file
@@ -0,0 +1,329 @@
|
||||
const video = document.getElementById('js-video-player');
|
||||
|
||||
window.hls = null;
|
||||
let hls = null;
|
||||
|
||||
// ===========
|
||||
// HLS NATIVE
|
||||
// ===========
|
||||
function initHLSNative(manifestUrl) {
|
||||
if (!manifestUrl) {
|
||||
console.error('No HLS manifest URL provided');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Initializing native HLS player with manifest:', manifestUrl);
|
||||
|
||||
if (hls) {
|
||||
window.hls = null;
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
}
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
startLevel: -1,
|
||||
});
|
||||
|
||||
window.hls = hls;
|
||||
|
||||
hls.loadSource(manifestUrl);
|
||||
hls.attachMedia(video);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) {
|
||||
console.log('Native manifest parsed');
|
||||
console.log('Levels:', data.levels.length);
|
||||
|
||||
const qualitySelect = document.getElementById('quality-select');
|
||||
|
||||
if (qualitySelect && data.levels?.length) {
|
||||
qualitySelect.innerHTML = '<option value="-1">Auto</option>';
|
||||
|
||||
const sorted = [...data.levels].sort((a, b) => b.height - a.height);
|
||||
const seen = new Set();
|
||||
|
||||
sorted.forEach(level => {
|
||||
if (!seen.has(level.height)) {
|
||||
seen.add(level.height);
|
||||
|
||||
const i = data.levels.indexOf(level);
|
||||
const opt = document.createElement('option');
|
||||
|
||||
opt.value = i;
|
||||
opt.textContent = level.height + 'p';
|
||||
|
||||
qualitySelect.appendChild(opt);
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial quality from settings
|
||||
if (typeof window.data !== 'undefined' && window.data.settings) {
|
||||
const defaultRes = window.data.settings.default_resolution;
|
||||
if (defaultRes !== 'auto' && defaultRes) {
|
||||
const target = parseInt(defaultRes);
|
||||
let bestLevel = -1;
|
||||
let bestHeight = 0;
|
||||
for (let i = 0; i < hls.levels.length; i++) {
|
||||
const h = hls.levels[i].height;
|
||||
if (h <= target && h > bestHeight) {
|
||||
bestHeight = h;
|
||||
bestLevel = i;
|
||||
}
|
||||
}
|
||||
if (bestLevel !== -1) {
|
||||
hls.currentLevel = bestLevel;
|
||||
qualitySelect.value = bestLevel;
|
||||
console.log('Starting at resolution:', bestHeight + 'p');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, function(_, data) {
|
||||
if (data.fatal) {
|
||||
console.error('HLS fatal error:', data.type, data.details);
|
||||
switch(data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
hls.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = manifestUrl;
|
||||
} else {
|
||||
console.error('HLS not supported');
|
||||
}
|
||||
}
|
||||
|
||||
// ======
|
||||
// INIT
|
||||
// ======
|
||||
function initPlayer() {
|
||||
console.log('Init native player');
|
||||
|
||||
if (typeof hls_manifest_url === 'undefined' || !hls_manifest_url) {
|
||||
console.error('No manifest URL');
|
||||
return;
|
||||
}
|
||||
|
||||
initHLSNative(hls_manifest_url);
|
||||
|
||||
const qualitySelect = document.getElementById('quality-select');
|
||||
if (qualitySelect) {
|
||||
qualitySelect.addEventListener('change', function () {
|
||||
const level = parseInt(this.value);
|
||||
|
||||
if (hls) {
|
||||
hls.currentLevel = level;
|
||||
console.log('Quality:', level === -1 ? 'Auto' : hls.levels[level]?.height + 'p');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// DOM READY
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initPlayer);
|
||||
} else {
|
||||
initPlayer();
|
||||
}
|
||||
|
||||
// =============
|
||||
// AUDIO TRACKS
|
||||
// =============
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const audioTrackSelect = document.getElementById('audio-track-select');
|
||||
|
||||
if (audioTrackSelect) {
|
||||
audioTrackSelect.addEventListener('change', function() {
|
||||
const trackIdx = parseInt(this.value);
|
||||
|
||||
if (!isNaN(trackIdx) && hls && hls.audioTracks && trackIdx >= 0 && trackIdx < hls.audioTracks.length) {
|
||||
hls.audioTrack = trackIdx;
|
||||
console.log('Audio track changed to:', hls.audioTracks[trackIdx].name || trackIdx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hls) {
|
||||
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, (_, data) => {
|
||||
console.log('Audio tracks:', data.audioTracks);
|
||||
|
||||
// Populate audio track select if needed
|
||||
if (audioTrackSelect && data.audioTracks.length > 0) {
|
||||
audioTrackSelect.innerHTML = '<option value="">Select audio track</option>';
|
||||
let originalIdx = -1;
|
||||
data.audioTracks.forEach((track, idx) => {
|
||||
// Find "original" track
|
||||
if (originalIdx === -1 && (track.name || '').toLowerCase().includes('original')) {
|
||||
originalIdx = idx;
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = String(idx);
|
||||
option.textContent = track.name || track.lang || `Track ${idx}`;
|
||||
audioTrackSelect.appendChild(option);
|
||||
});
|
||||
audioTrackSelect.disabled = false;
|
||||
|
||||
// Auto-select "original" audio track
|
||||
if (originalIdx !== -1) {
|
||||
hls.audioTrack = originalIdx;
|
||||
audioTrackSelect.value = String(originalIdx);
|
||||
console.log('Auto-selected original audio track:', data.audioTracks[originalIdx].name);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ============
|
||||
// START TIME
|
||||
// ============
|
||||
if (typeof data !== 'undefined' && data.time_start != 0 && video) {
|
||||
video.addEventListener('loadedmetadata', function() {
|
||||
video.currentTime = data.time_start;
|
||||
});
|
||||
}
|
||||
|
||||
// ==============
|
||||
// SPEED CONTROL
|
||||
// ==============
|
||||
let speedInput = document.getElementById('speed-control');
|
||||
|
||||
if (speedInput) {
|
||||
speedInput.addEventListener('keyup', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
let speed = parseFloat(speedInput.value);
|
||||
if(!isNaN(speed)){
|
||||
video.playbackRate = speed;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =========
|
||||
// Autoplay
|
||||
// =========
|
||||
(function() {
|
||||
if (typeof data === 'undefined' || (data.settings.related_videos_mode === 0 && data.playlist === null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let playability_error = !!data.playability_error;
|
||||
let isPlaylist = false;
|
||||
if (data.playlist !== null && data.playlist['current_index'] !== null)
|
||||
isPlaylist = true;
|
||||
|
||||
// read cookies on whether to autoplay
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie
|
||||
let cookieValue;
|
||||
let playlist_id;
|
||||
if (isPlaylist) {
|
||||
// from https://stackoverflow.com/a/6969486
|
||||
function escapeRegExp(string) {
|
||||
// $& means the whole matched string
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
playlist_id = data.playlist['id'];
|
||||
playlist_id = escapeRegExp(playlist_id);
|
||||
|
||||
cookieValue = document.cookie.replace(new RegExp(
|
||||
'(?:(?:^|.*;\\s*)autoplay_'
|
||||
+ playlist_id + '\\s*\\=\\s*([^;]*).*$)|^.*$'
|
||||
), '$1');
|
||||
} else {
|
||||
cookieValue = document.cookie.replace(new RegExp(
|
||||
'(?:(?:^|.*;\\s*)autoplay\\s*\\=\\s*([^;]*).*$)|^.*$'
|
||||
),'$1');
|
||||
}
|
||||
|
||||
let autoplayEnabled = 0;
|
||||
if(cookieValue.length === 0){
|
||||
autoplayEnabled = 0;
|
||||
} else {
|
||||
autoplayEnabled = Number(cookieValue);
|
||||
}
|
||||
|
||||
// check the checkbox if autoplay is on
|
||||
let checkbox = document.querySelector('.autoplay-toggle');
|
||||
if(autoplayEnabled){
|
||||
checkbox.checked = true;
|
||||
}
|
||||
|
||||
// listen for checkbox to turn autoplay on and off
|
||||
let cookie = 'autoplay'
|
||||
if (isPlaylist)
|
||||
cookie += '_' + playlist_id;
|
||||
|
||||
checkbox.addEventListener( 'change', function() {
|
||||
if(this.checked) {
|
||||
autoplayEnabled = 1;
|
||||
document.cookie = cookie + '=1; SameSite=Strict';
|
||||
} else {
|
||||
autoplayEnabled = 0;
|
||||
document.cookie = cookie + '=0; SameSite=Strict';
|
||||
}
|
||||
});
|
||||
|
||||
if(!playability_error){
|
||||
// play the video if autoplay is on
|
||||
if(autoplayEnabled){
|
||||
video.play().catch(function(e) {
|
||||
// Autoplay blocked by browser - ignore silently
|
||||
console.log('Autoplay blocked:', e.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// determine next video url
|
||||
let nextVideoUrl;
|
||||
if (isPlaylist) {
|
||||
let currentIndex = data.playlist['current_index'];
|
||||
if (data.playlist['current_index']+1 == data.playlist['items'].length)
|
||||
nextVideoUrl = null;
|
||||
else
|
||||
nextVideoUrl = data.playlist['items'][data.playlist['current_index']+1]['url'];
|
||||
|
||||
// scroll playlist to proper position
|
||||
// item height + gap == 100
|
||||
let pl = document.querySelector('.playlist-videos');
|
||||
pl.scrollTop = 100*currentIndex;
|
||||
} else {
|
||||
if (data.related.length === 0)
|
||||
nextVideoUrl = null;
|
||||
else
|
||||
nextVideoUrl = data.related[0]['url'];
|
||||
}
|
||||
let nextVideoDelay = 1000;
|
||||
|
||||
// go to next video when video ends
|
||||
// https://stackoverflow.com/a/2880950
|
||||
if (nextVideoUrl) {
|
||||
if(playability_error){
|
||||
videoEnded();
|
||||
} else {
|
||||
video.addEventListener('ended', videoEnded, false);
|
||||
}
|
||||
function nextVideo(){
|
||||
if(autoplayEnabled){
|
||||
window.location.href = nextVideoUrl;
|
||||
}
|
||||
}
|
||||
function videoEnded(e) {
|
||||
window.setTimeout(nextVideo, nextVideoDelay);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -44,13 +44,14 @@ e.g. Firefox playback speed options */
|
||||
.plyr__controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.plyr__progress__container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
margin-bottom: -10px;
|
||||
margin-bottom: -5px;
|
||||
}
|
||||
|
||||
.plyr__controls .plyr__controls__item:first-child {
|
||||
@@ -72,6 +73,120 @@ e.g. Firefox playback speed options */
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Plyr Custom Controls
|
||||
*/
|
||||
|
||||
.plyr__control svg.hls_audio_icon,
|
||||
.plyr__control svg.hls_quality_icon {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.plyr__control[data-plyr="quality-custom"],
|
||||
.plyr__control[data-plyr="audio-custom"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.plyr__control[data-plyr="quality-custom"]:hover,
|
||||
.plyr__control[data-plyr="audio-custom"]:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/*
|
||||
* Custom styles for dropdown controls
|
||||
*/
|
||||
.plyr__control--custom {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Quality and Audio containers */
|
||||
#plyr-quality-container,
|
||||
#plyr-audio-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Quality and Audio buttons */
|
||||
#plyr-quality-container .plyr__control,
|
||||
#plyr-audio-container .plyr__control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Text labels */
|
||||
#plyr-quality-text,
|
||||
#plyr-audio-text {
|
||||
font-size: 12px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Dropdowns */
|
||||
.plyr-quality-dropdown,
|
||||
.plyr-audio-dropdown {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background: #E6E6E6;
|
||||
color: #23282f;
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
min-width: 90px;
|
||||
display: none;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Audio dropdown needs slightly wider */
|
||||
.plyr-audio-dropdown {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Dropdown options */
|
||||
.plyr-quality-option,
|
||||
.plyr-audio-option {
|
||||
padding: 6px 16px;
|
||||
margin-bottom: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
color: #23282f;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Active/selected option */
|
||||
.plyr-quality-option[data-active="true"],
|
||||
.plyr-audio-option[data-active="true"] {
|
||||
background: #00b3ff;
|
||||
color: #FFF;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Hover state */
|
||||
.plyr-quality-option:hover,
|
||||
.plyr-audio-option:hover {
|
||||
background: #00b3ff;
|
||||
color: #FFF;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* No audio tracks message */
|
||||
.plyr-audio-no-tracks {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* End custom styles
|
||||
*/
|
||||
|
||||
@@ -30,8 +30,7 @@ database_path = os.path.join(settings.data_dir, "subscriptions.sqlite")
|
||||
|
||||
|
||||
def open_database():
|
||||
if not os.path.exists(settings.data_dir):
|
||||
os.makedirs(settings.data_dir)
|
||||
os.makedirs(settings.data_dir, exist_ok=True)
|
||||
connection = sqlite3.connect(database_path, check_same_thread=False)
|
||||
|
||||
try:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' blob: {{ app_url }}/* data: https://*.googlevideo.com; {{ "img-src 'self' https://*.googleusercontent.com https://*.ggpht.com https://*.ytimg.com;" if not settings.proxy_images else "" }}">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; media-src 'self' blob: {{ app_url }}/* data: https://*.googlevideo.com; img-src 'self' https://*.googleusercontent.com https://*.ggpht.com https://*.ytimg.com; connect-src 'self' https://*.googlevideo.com; font-src 'self' data:; worker-src 'self' blob:;">
|
||||
<title>{{ page_title }}</title>
|
||||
<link title="YT Local" href="/youtube.com/opensearch.xml" rel="search" type="application/opensearchdescription+xml">
|
||||
<link href="/youtube.com/static/favicon.ico" type="image/x-icon" rel="icon">
|
||||
|
||||
@@ -82,7 +82,11 @@
|
||||
<div id="links-metadata">
|
||||
{% if current_tab in ('videos', 'shorts', 'streams') %}
|
||||
{% set sorts = [('3', 'newest'), ('4', 'newest - no shorts')] %}
|
||||
<div id="number-of-results">{{ number_of_videos }} videos</div>
|
||||
{% if current_tab in ('shorts', 'streams') and not is_last_page %}
|
||||
<div id="number-of-results">{{ number_of_videos }}+ videos</div>
|
||||
{% else %}
|
||||
<div id="number-of-results">{{ number_of_videos }} videos</div>
|
||||
{% endif %}
|
||||
{% elif current_tab == 'playlists' %}
|
||||
{% set sorts = [('3', 'newest'), ('4', 'last video added')] %}
|
||||
{% if items %}
|
||||
@@ -117,7 +121,11 @@
|
||||
<hr/>
|
||||
|
||||
<footer class="pagination-container">
|
||||
{% if current_tab in ('videos', 'shorts', 'streams') %}
|
||||
{% if current_tab in ('shorts', 'streams') %}
|
||||
<nav class="next-previous-button-row">
|
||||
{{ common_elements.next_previous_buttons(is_last_page, channel_url + '/' + current_tab, parameters_dictionary) }}
|
||||
</nav>
|
||||
{% elif current_tab == 'videos' %}
|
||||
<nav class="pagination-list">
|
||||
{{ common_elements.page_buttons(number_of_pages, channel_url + '/' + current_tab, parameters_dictionary, include_ends=(current_sort.__str__() in '34')) }}
|
||||
</nav>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if 'more_comments_url' is in comments_info %}
|
||||
<a class="page-button more-comments" href="{{ comments_info['more_comments_url'] }}">More comments</a>
|
||||
<a class="page-button more-comments" href="{{ comments_info['more_comments_url'] }}">{{ _('More comments') }}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -58,7 +58,9 @@
|
||||
|
||||
<div class="stats {{'horizontal-stats' if horizontal else 'vertical-stats'}}">
|
||||
{% if info['type'] == 'channel' %}
|
||||
<div>{{ info['approx_subscriber_count'] }} subscribers</div>
|
||||
{% if info.get('approx_subscriber_count') %}
|
||||
<div>{{ info['approx_subscriber_count'] }} subscribers</div>
|
||||
{% endif %}
|
||||
<div>{{ info['video_count']|commatize }} videos</div>
|
||||
{% else %}
|
||||
{% if info.get('time_published') %}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline'; media-src 'self' https://*.googlevideo.com; {{ "img-src 'self' https://*.googleusercontent.com https://*.ggpht.com https://*.ytimg.com;" if not settings.proxy_images else "" }}">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' blob: https://*.googlevideo.com; img-src 'self' https://*.googleusercontent.com https://*.ggpht.com https://*.ytimg.com; connect-src 'self' https://*.googlevideo.com; font-src 'self' data:;">
|
||||
<title>{{ title }}</title>
|
||||
<link href="/youtube.com/static/favicon.ico" type="image/x-icon" rel="icon">
|
||||
{% if settings.use_video_player == 2 %}
|
||||
<!-- plyr -->
|
||||
<link href="/youtube.com/static/modules/plyr/plyr.css" rel="stylesheet">
|
||||
<!--/ plyr -->
|
||||
<!-- /plyr -->
|
||||
{% endif %}
|
||||
<style>
|
||||
body {
|
||||
@@ -37,9 +37,6 @@
|
||||
<body>
|
||||
<video id="js-video-player" controls autofocus onmouseleave="{{ title }}"
|
||||
oncontextmenu="{{ title }}" onmouseenter="{{ title }}" title="{{ title }}">
|
||||
{% if uni_sources %}
|
||||
<source src="{{ uni_sources[uni_idx]['url'] }}" type="{{ uni_sources[uni_idx]['type'] }}" data-res="{{ uni_sources[uni_idx]['quality'] }}">
|
||||
{% endif %}
|
||||
{% for source in subtitle_sources %}
|
||||
{% if source['on'] %}
|
||||
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}" default>
|
||||
@@ -47,28 +44,66 @@
|
||||
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if uni_sources %}
|
||||
{% for source in uni_sources %}
|
||||
<source src="{{ source['url'] }}" type="{{ source['type'] }}" title="{{ source['quality_string'] }}">
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</video>
|
||||
{% if js_data %}
|
||||
<script>
|
||||
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
|
||||
data = {{ js_data|tojson }};
|
||||
// @license-end
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
|
||||
let storyboard_url = {{ storyboard_url | tojson }};
|
||||
let hls_manifest_url = {{ hls_manifest_url | tojson }};
|
||||
let hls_unavailable = {{ hls_unavailable | tojson }};
|
||||
let playback_mode = {{ playback_mode | tojson }};
|
||||
let pair_sources = {{ pair_sources | tojson }};
|
||||
let pair_idx = {{ pair_idx | tojson }};
|
||||
// @license-end
|
||||
</script>
|
||||
{% if settings.use_video_player == 2 %}
|
||||
|
||||
{% set hls_should_work = (playback_mode == 'hls' or playback_mode == 'auto') and not hls_unavailable %}
|
||||
{% set use_dash = not hls_should_work %}
|
||||
|
||||
{% if not use_dash %}
|
||||
<script src="/youtube.com/static/js/hls.min.js"
|
||||
integrity="sha512-CSVqc4a7tn+tizDNt+eDoVn2fXYAwMDpCLrwGlWrOktNfZQ9gp4dKKScElMeRlrIifhliXs0a06BLaUgmMlCUw=="
|
||||
crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
|
||||
<script src="/youtube.com/static/js/common.js"></script>
|
||||
|
||||
{% if settings.use_video_player == 0 %}
|
||||
<!-- Native player -->
|
||||
{% if use_dash %}
|
||||
<script src="/youtube.com/static/js/watch.dash.js"></script>
|
||||
{% else %}
|
||||
<script src="/youtube.com/static/js/watch.hls.js"></script>
|
||||
{% endif %}
|
||||
{% elif settings.use_video_player == 1 %}
|
||||
<!-- Native player with hotkeys -->
|
||||
<script src="/youtube.com/static/js/hotkeys.js"></script>
|
||||
{% if use_dash %}
|
||||
<script src="/youtube.com/static/js/watch.dash.js"></script>
|
||||
{% else %}
|
||||
<script src="/youtube.com/static/js/watch.hls.js"></script>
|
||||
{% endif %}
|
||||
{% elif settings.use_video_player == 2 %}
|
||||
<!-- plyr -->
|
||||
<script src="/youtube.com/static/modules/plyr/plyr.min.js"
|
||||
integrity="sha512-l6ZzdXpfMHRfifqaR79wbYCEWjLDMI9DnROvb+oLkKq6d7MGroGpMbI7HFpicvmAH/2aQO+vJhewq8rhysrImw=="
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="/youtube.com/static/js/plyr-start.js"></script>
|
||||
{% if use_dash %}
|
||||
<script src="/youtube.com/static/js/plyr.dash.start.js"></script>
|
||||
{% else %}
|
||||
<script src="/youtube.com/static/js/plyr.hls.start.js"></script>
|
||||
{% endif %}
|
||||
<!-- /plyr -->
|
||||
{% elif settings.use_video_player == 1 %}
|
||||
<script src="/youtube.com/static/js/hotkeys.js"></script>
|
||||
{% endif %}
|
||||
|
||||
{% if use_dash %}
|
||||
<script src="/youtube.com/static/js/av-merge.js"></script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
|
||||
<td data-label="Source"><a href="/youtube.com/static/js/common.js">common.js</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="File"><a href="/youtube.com/static/js/hls.min.js">hls.min.js</a></td>
|
||||
<td data-label="License"><a href="https://spdx.org/licenses/BSD-3-Clause.html">BSD-3-Clause</a></td>
|
||||
<td data-label="Source"><a href="https://github.com/video-dev/hls.js/tree/v1.6.15/src">hls.js v1.6.15 source</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="File"><a href="/youtube.com/static/js/hotkeys.js">hotkeys.js</a></td>
|
||||
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
|
||||
@@ -40,9 +45,24 @@
|
||||
<td data-label="Source"><a href="/youtube.com/static/js/playlistadd.js">playlistadd.js</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="File"><a href="/youtube.com/static/js/plyr-start.js">plyr-start.js</a></td>
|
||||
<td data-label="File"><a href="/youtube.com/static/js/plyr.dash.start.js">plyr.dash.start.js</a></td>
|
||||
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
|
||||
<td data-label="Source"><a href="/youtube.com/static/js/plyr-start.js">plyr-start.js</a></td>
|
||||
<td data-label="Source"><a href="/youtube.com/static/js/plyr.dash.start.js">plyr.dash.start.js</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="File"><a href="/youtube.com/static/js/plyr.hls.start.js">plyr.hls.start.js</a></td>
|
||||
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
|
||||
<td data-label="Source"><a href="/youtube.com/static/js/plyr.hls.start.js">plyr.hls.start.js</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="File"><a href="/youtube.com/static/js/sponsorblock.js">sponsorblock.js</a></td>
|
||||
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
|
||||
<td data-label="Source"><a href="/youtube.com/static/js/sponsorblock.js">sponsorblock.js</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="File"><a href="/youtube.com/static/js/storyboard-preview.js">storyboard-preview.js</a></td>
|
||||
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
|
||||
<td data-label="Source"><a href="/youtube.com/static/js/storyboard-preview.js">storyboard-preview.js</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="File"><a href="/youtube.com/static/modules/plyr/plyr.min.js">plyr.min.js</a></td>
|
||||
@@ -55,9 +75,14 @@
|
||||
<td data-label="Source"><a href="/youtube.com/static/js/transcript-table.js">transcript-table.js</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="File"><a href="/youtube.com/static/js/watch.js">watch.js</a></td>
|
||||
<td data-label="File"><a href="/youtube.com/static/js/watch.dash.js">watch.dash.js</a></td>
|
||||
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
|
||||
<td data-label="Source"><a href="/youtube.com/static/js/watch.js">watch.js</a></td>
|
||||
<td data-label="Source"><a href="/youtube.com/static/js/watch.dash.js">watch.dash.js</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="File"><a href="/youtube.com/static/js/watch.hls.js">watch.hls.js</a></td>
|
||||
<td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
|
||||
<td data-label="Source"><a href="/youtube.com/static/js/watch.hls.js">watch.hls.js</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
{% block main %}
|
||||
<form method="POST" class="settings-form">
|
||||
{% for categ in categories %}
|
||||
<h2>{{ categ|capitalize }}</h2>
|
||||
<h2>{{ _(categ|capitalize) }}</h2>
|
||||
<ul class="settings-list">
|
||||
{% for setting_name, setting_info, value in settings_by_category[categ] %}
|
||||
{% if not setting_info.get('hidden', false) %}
|
||||
<li class="setting-item">
|
||||
{% if 'label' is in(setting_info) %}
|
||||
<label for="{{ 'setting_' + setting_name }}" {% if 'comment' is in(setting_info) %}title="{{ setting_info['comment'] }}" {% endif %}>{{ setting_info['label'] }}</label>
|
||||
<label for="{{ 'setting_' + setting_name }}" {% if 'comment' is in(setting_info) %}title="{{ setting_info['comment'] }}" {% endif %}>{{ _(setting_info['label']) }}</label>
|
||||
{% else %}
|
||||
<label for="{{ 'setting_' + setting_name }}" {% if 'comment' is in(setting_info) %}title="{{ setting_info['comment'] }}" {% endif %}>{{ setting_name.replace('_', ' ')|capitalize }}</label>
|
||||
<label for="{{ 'setting_' + setting_name }}" {% if 'comment' is in(setting_info) %}title="{{ setting_info['comment'] }}" {% endif %}>{{ _(setting_name.replace('_', ' ')|capitalize) }}</label>
|
||||
{% endif %}
|
||||
|
||||
{% if setting_info['type'].__name__ == 'bool' %}
|
||||
@@ -24,7 +24,7 @@
|
||||
{% if 'options' is in(setting_info) %}
|
||||
<select id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}">
|
||||
{% for option in setting_info['options'] %}
|
||||
<option value="{{ option[0] }}" {{ 'selected' if option[0] == value else '' }}>{{ option[1] }}</option>
|
||||
<option value="{{ option[0] }}" {{ 'selected' if option[0] == value else '' }}>{{ _(option[1]) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
@@ -36,7 +36,7 @@
|
||||
{% if 'options' is in(setting_info) %}
|
||||
<select id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}">
|
||||
{% for option in setting_info['options'] %}
|
||||
<option value="{{ option[0] }}" {{ 'selected' if option[0] == value else '' }}>{{ option[1] }}</option>
|
||||
<option value="{{ option[0] }}" {{ 'selected' if option[0] == value else '' }}>{{ _(option[1]) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
@@ -50,6 +50,6 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
<input type="submit" value="Save settings">
|
||||
<input type="submit" value="{{ _('Save settings') }}">
|
||||
</form>
|
||||
{% endblock main %}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<!-- plyr -->
|
||||
<link href="/youtube.com/static/modules/plyr/plyr.css" rel="stylesheet">
|
||||
<link href="/youtube.com/static/modules/plyr/custom_plyr.css" rel="stylesheet">
|
||||
<!--/ plyr -->
|
||||
<!-- /plyr -->
|
||||
{% endif %}
|
||||
{% endblock style %}
|
||||
|
||||
@@ -23,22 +23,9 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% elif (uni_sources.__len__() == 0 or live) and hls_formats.__len__() != 0 %}
|
||||
<div class="live-url-choices">
|
||||
<span>Copy a url into your video player:</span>
|
||||
<ol>
|
||||
{% for fmt in hls_formats %}
|
||||
<li class="url-choice"><div class="url-choice-label">{{ fmt['video_quality'] }}: </div><input class="url-choice-copy" value="{{ fmt['url'] }}" readonly onclick="this.select();"></li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{% else %}
|
||||
<figure class="sc-video">
|
||||
<video id="js-video-player" playsinline controls {{ 'autoplay' if settings.autoplay_videos }}>
|
||||
{% if uni_sources %}
|
||||
<source src="{{ uni_sources[uni_idx]['url'] }}" type="{{ uni_sources[uni_idx]['type'] }}" data-res="{{ uni_sources[uni_idx]['quality'] }}">
|
||||
{% endif %}
|
||||
|
||||
{% for source in subtitle_sources %}
|
||||
{% if source['on'] %}
|
||||
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}" default>
|
||||
@@ -46,7 +33,18 @@
|
||||
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if uni_sources %}
|
||||
{% for source in uni_sources %}
|
||||
<source src="{{ source['url'] }}" type="{{ source['type'] }}" title="{{ source['quality_string'] }}">
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</video>
|
||||
{% if hls_unavailable and not uni_sources %}
|
||||
<div class="playability-error">
|
||||
<span>Error: HLS streams unavailable. Video may not play without JavaScript fallback.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</figure>
|
||||
{% endif %}
|
||||
|
||||
@@ -76,25 +74,34 @@
|
||||
|
||||
<div class="external-player-controls">
|
||||
<input class="speed" id="speed-control" type="text" title="Video speed">
|
||||
{% if settings.use_video_player < 2 %}
|
||||
<!-- Native player quality selector -->
|
||||
<select id="quality-select" autocomplete="off">
|
||||
<option value="-1" selected>Auto</option>
|
||||
<!-- Quality options will be populated by HLS -->
|
||||
</select>
|
||||
{% else %}
|
||||
<select id="quality-select" autocomplete="off" style="display: none;">
|
||||
<!-- Quality options will be populated by HLS -->
|
||||
</select>
|
||||
{% endif %}
|
||||
{% if settings.use_video_player != 2 %}
|
||||
<select id="quality-select" autocomplete="off">
|
||||
{% for src in uni_sources %}
|
||||
<option value='{"type": "uni", "index": {{ loop.index0 }}}' {{ 'selected' if loop.index0 == uni_idx and not using_pair_sources else '' }} >{{ src['quality_string'] }}</option>
|
||||
{% endfor %}
|
||||
{% for src_pair in pair_sources %}
|
||||
<option value='{"type": "pair", "index": {{ loop.index0}}}' {{ 'selected' if loop.index0 == pair_idx and using_pair_sources else '' }} >{{ src_pair['quality_string'] }}</option>
|
||||
{% if audio_tracks|length > 1 %}
|
||||
<select id="audio-track-select" autocomplete="off">
|
||||
{% for track in audio_tracks %}
|
||||
<option value="{{ track['id'] }}" {{ 'selected' if track['is_default'] else '' }}>{{ track['name'] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<input class="v-checkbox" name="video_info_list" value="{{ video_info }}" form="playlist-edit" type="checkbox">
|
||||
|
||||
<span class="v-direct-link"><a href="https://youtu.be/{{ video_id }}" rel="noopener noreferrer" target="_blank">Direct Link</a></span>
|
||||
<span class="v-direct-link"><a href="https://youtu.be/{{ video_id }}" rel="noopener noreferrer" target="_blank">{{ _('Direct Link') }}</a></span>
|
||||
|
||||
{% if settings.use_video_download != 0 %}
|
||||
<details class="v-download">
|
||||
<summary class="download-dropdown-label">Download</summary>
|
||||
<summary class="download-dropdown-label">{{ _('Download') }}</summary>
|
||||
<ul class="download-dropdown-content">
|
||||
{% for format in download_formats %}
|
||||
<li class="download-format">
|
||||
@@ -142,7 +149,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<details class="v-more-info">
|
||||
<summary>More info</summary>
|
||||
<summary>{{ _('More info') }}</summary>
|
||||
<div class="more-info-content">
|
||||
<p>Tor exit node: {{ ip_address }}</p>
|
||||
{% if invidious_used %}
|
||||
@@ -166,7 +173,7 @@
|
||||
<div class="playlist-header">
|
||||
<a href="{{ playlist['url'] }}" title="{{ playlist['title'] }}"><h3>{{ playlist['title'] }}</h3></a>
|
||||
<ul class="playlist-metadata">
|
||||
<li><label for="playlist-autoplay-toggle">Autoplay: </label><input id="playlist-autoplay-toggle" type="checkbox" class="autoplay-toggle"></li>
|
||||
<li><label for="playlist-autoplay-toggle">{{ _('AutoNext') }}: </label><input id="playlist-autoplay-toggle" type="checkbox" class="autoplay-toggle"></li>
|
||||
{% if playlist['current_index'] is none %}
|
||||
<li>[Error!]/{{ playlist['video_count'] }}</li>
|
||||
{% else %}
|
||||
@@ -193,7 +200,7 @@
|
||||
</nav>
|
||||
</div>
|
||||
{% elif settings.related_videos_mode != 0 %}
|
||||
<div class="related-autoplay"><label for="related-autoplay-toggle">Autoplay: </label><input id="related-autoplay-toggle" type="checkbox" class="autoplay-toggle"></div>
|
||||
<div class="related-autoplay"><label for="related-autoplay-toggle">{{ _('AutoNext') }}: </label><input id="related-autoplay-toggle" type="checkbox" class="autoplay-toggle"></div>
|
||||
{% endif %}
|
||||
|
||||
{% if subtitle_sources %}
|
||||
@@ -215,7 +222,7 @@
|
||||
|
||||
{% if settings.related_videos_mode != 0 %}
|
||||
<details class="related-videos-outer" {{'open' if settings.related_videos_mode == 1 else ''}}>
|
||||
<summary>Related Videos</summary>
|
||||
<summary>{{ _('Related Videos') }}</summary>
|
||||
<nav class="related-videos-inner">
|
||||
{% for info in related %}
|
||||
{{ common_elements.item(info, include_badges=false) }}
|
||||
@@ -229,10 +236,10 @@
|
||||
<!-- comments -->
|
||||
{% if settings.comments_mode != 0 %}
|
||||
{% if comments_disabled %}
|
||||
<div class="comments-area-outer comments-disabled">Comments disabled</div>
|
||||
<div class="comments-area-outer comments-disabled">{{ _('Comments disabled') }}</div>
|
||||
{% else %}
|
||||
<details class="comments-area-outer" {{'open' if settings.comments_mode == 1 else ''}}>
|
||||
<summary>{{ comment_count|commatize }} comment{{'s' if comment_count != '1' else ''}}</summary>
|
||||
<summary>{{ comment_count|commatize }} {{ _('Comment') }}{{'s' if comment_count != '1' else ''}}</summary>
|
||||
<div class="comments-area-inner comments-area">
|
||||
{% if comments_info %}
|
||||
{{ comments.video_comments(comments_info) }}
|
||||
@@ -244,26 +251,64 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/youtube.com/static/js/av-merge.js"></script>
|
||||
<script src="/youtube.com/static/js/watch.js"></script>
|
||||
<script>
|
||||
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
|
||||
let storyboard_url = {{ storyboard_url | tojson }};
|
||||
let hls_manifest_url = {{ hls_manifest_url | tojson }};
|
||||
let hls_unavailable = {{ hls_unavailable | tojson }};
|
||||
let playback_mode = {{ playback_mode | tojson }};
|
||||
let pair_sources = {{ pair_sources | tojson }};
|
||||
let pair_idx = {{ pair_idx | tojson }};
|
||||
// @license-end
|
||||
</script>
|
||||
|
||||
<script src="/youtube.com/static/js/common.js"></script>
|
||||
<script src="/youtube.com/static/js/transcript-table.js"></script>
|
||||
{% if settings.use_video_player == 2 %}
|
||||
|
||||
{% set hls_should_work = (playback_mode == 'hls' or playback_mode == 'auto') and not hls_unavailable %}
|
||||
{% set use_dash = not hls_should_work %}
|
||||
|
||||
{% if use_dash %}
|
||||
<script src="/youtube.com/static/js/av-merge.js"></script>
|
||||
{% else %}
|
||||
<script src="/youtube.com/static/js/hls.min.js"
|
||||
integrity="sha512-CSVqc4a7tn+tizDNt+eDoVn2fXYAwMDpCLrwGlWrOktNfZQ9gp4dKKScElMeRlrIifhliXs0a06BLaUgmMlCUw=="
|
||||
crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
|
||||
{% if settings.use_video_player == 0 %}
|
||||
<!-- Native player (no hotkeys) -->
|
||||
{% if use_dash %}
|
||||
<script src="/youtube.com/static/js/watch.dash.js"></script>
|
||||
{% else %}
|
||||
<script src="/youtube.com/static/js/watch.hls.js"></script>
|
||||
{% endif %}
|
||||
{% elif settings.use_video_player == 1 %}
|
||||
<!-- Native player with hotkeys -->
|
||||
<script src="/youtube.com/static/js/hotkeys.js"></script>
|
||||
{% if use_dash %}
|
||||
<script src="/youtube.com/static/js/watch.dash.js"></script>
|
||||
{% else %}
|
||||
<script src="/youtube.com/static/js/watch.hls.js"></script>
|
||||
{% endif %}
|
||||
{% elif settings.use_video_player == 2 %}
|
||||
<!-- plyr -->
|
||||
<script src="/youtube.com/static/modules/plyr/plyr.min.js"
|
||||
integrity="sha512-l6ZzdXpfMHRfifqaR79wbYCEWjLDMI9DnROvb+oLkKq6d7MGroGpMbI7HFpicvmAH/2aQO+vJhewq8rhysrImw=="
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="/youtube.com/static/js/plyr-start.js"></script>
|
||||
{% if use_dash %}
|
||||
<script src="/youtube.com/static/js/plyr.dash.start.js"></script>
|
||||
{% else %}
|
||||
<script src="/youtube.com/static/js/plyr.hls.start.js"></script>
|
||||
{% endif %}
|
||||
<!-- /plyr -->
|
||||
{% elif settings.use_video_player == 1 %}
|
||||
<script src="/youtube.com/static/js/hotkeys.js"></script>
|
||||
{% endif %}
|
||||
|
||||
<!-- Storyboard Preview Thumbnails -->
|
||||
{% if settings.use_video_player != 2 %}
|
||||
<script src="/youtube.com/static/js/storyboard-preview.js"></script>
|
||||
{% endif %}
|
||||
|
||||
{% if settings.use_comments_js %} <script src="/youtube.com/static/js/comments.js"></script> {% endif %}
|
||||
{% if settings.use_sponsorblock_js %} <script src="/youtube.com/static/js/sponsorblock.js"></script> {% endif %}
|
||||
{% endblock main %}
|
||||
|
||||
@@ -343,8 +343,7 @@ def fetch_url(url, headers=(), timeout=15, report_text=None, data=None,
|
||||
and debug_name is not None
|
||||
and content):
|
||||
save_dir = os.path.join(settings.data_dir, 'debug')
|
||||
if not os.path.exists(save_dir):
|
||||
os.makedirs(save_dir)
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
with open(os.path.join(save_dir, debug_name), 'wb') as f:
|
||||
f.write(content)
|
||||
@@ -838,9 +837,12 @@ INNERTUBE_CLIENTS = {
|
||||
'hl': 'en',
|
||||
'gl': 'US',
|
||||
'clientName': 'IOS',
|
||||
'clientVersion': '19.09.3',
|
||||
'deviceModel': 'iPhone14,3',
|
||||
'userAgent': 'com.google.ios.youtube/19.09.3 (iPhone14,3; U; CPU iOS 15_6 like Mac OS X)'
|
||||
'clientVersion': '21.03.2',
|
||||
'deviceMake': 'Apple',
|
||||
'deviceModel': 'iPhone16,2',
|
||||
'osName': 'iPhone',
|
||||
'osVersion': '18.7.2.22H124',
|
||||
'userAgent': 'com.google.ios.youtube/21.03.2 (iPhone16,2; U; CPU iOS 18_7_2 like Mac OS X)'
|
||||
}
|
||||
},
|
||||
'INNERTUBE_CONTEXT_CLIENT_NAME': 5,
|
||||
@@ -897,13 +899,31 @@ INNERTUBE_CLIENTS = {
|
||||
'INNERTUBE_CONTEXT_CLIENT_NAME': 28,
|
||||
'REQUIRE_JS_PLAYER': False,
|
||||
},
|
||||
|
||||
'ios_vr': {
|
||||
'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
|
||||
'INNERTUBE_CONTEXT': {
|
||||
'client': {
|
||||
'hl': 'en',
|
||||
'gl': 'US',
|
||||
'clientName': 'IOS_VR',
|
||||
'clientVersion': '1.0',
|
||||
'deviceMake': 'Apple',
|
||||
'deviceModel': 'iPhone16,2',
|
||||
'osName': 'iPhone',
|
||||
'osVersion': '18.7.2.22H124',
|
||||
'userAgent': 'com.google.ios.youtube/1.0 (iPhone16,2; U; CPU iOS 18_7_2 like Mac OS X)'
|
||||
}
|
||||
},
|
||||
'INNERTUBE_CONTEXT_CLIENT_NAME': 5,
|
||||
'REQUIRE_JS_PLAYER': False
|
||||
},
|
||||
}
|
||||
|
||||
def get_visitor_data():
|
||||
visitor_data = None
|
||||
visitor_data_cache = os.path.join(settings.data_dir, 'visitorData.txt')
|
||||
if not os.path.exists(settings.data_dir):
|
||||
os.makedirs(settings.data_dir)
|
||||
os.makedirs(settings.data_dir, exist_ok=True)
|
||||
if os.path.isfile(visitor_data_cache):
|
||||
with open(visitor_data_cache, 'r') as file:
|
||||
print('Getting visitor_data from cache')
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
__version__ = 'v0.4.2'
|
||||
__version__ = 'v0.4.5'
|
||||
|
||||
636
youtube/watch.py
636
youtube/watch.py
@@ -42,73 +42,68 @@ def codec_name(vcodec):
|
||||
|
||||
|
||||
def get_video_sources(info, target_resolution):
|
||||
'''return dict with organized sources: {
|
||||
'uni_sources': [{}, ...], # video and audio in one file
|
||||
'uni_idx': int, # default unified source index
|
||||
'pair_sources': [{video: {}, audio: {}, quality: ..., ...}, ...],
|
||||
'pair_idx': int, # default pair source index
|
||||
}
|
||||
'''
|
||||
audio_sources = []
|
||||
'''return dict with organized sources'''
|
||||
audio_by_track = {}
|
||||
video_only_sources = {}
|
||||
uni_sources = []
|
||||
pair_sources = []
|
||||
|
||||
|
||||
for fmt in info['formats']:
|
||||
if not all(fmt[attr] for attr in ('ext', 'url', 'itag')):
|
||||
continue
|
||||
|
||||
# unified source
|
||||
if fmt['acodec'] and fmt['vcodec']:
|
||||
source = {
|
||||
'type': 'video/' + fmt['ext'],
|
||||
'quality_string': short_video_quality_string(fmt),
|
||||
}
|
||||
if fmt.get('audio_track_is_default', True) is False:
|
||||
continue
|
||||
source = {'type': 'video/' + fmt['ext'],
|
||||
'quality_string': short_video_quality_string(fmt)}
|
||||
source['quality_string'] += ' (integrated)'
|
||||
source.update(fmt)
|
||||
uni_sources.append(source)
|
||||
continue
|
||||
|
||||
if not (fmt['init_range'] and fmt['index_range']):
|
||||
continue
|
||||
|
||||
# audio source
|
||||
if fmt['acodec'] and not fmt['vcodec'] and (
|
||||
fmt['audio_bitrate'] or fmt['bitrate']):
|
||||
if fmt['bitrate']: # prefer this one, more accurate right now
|
||||
# Allow HLS-backed audio tracks (served locally, no init/index needed)
|
||||
if not fmt.get('url', '').startswith('http://127.') and not '/ytl-api/' in fmt.get('url', ''):
|
||||
continue
|
||||
# Mark as HLS for frontend
|
||||
fmt['is_hls'] = True
|
||||
if fmt['acodec'] and not fmt['vcodec'] and (fmt['audio_bitrate'] or fmt['bitrate']):
|
||||
if fmt['bitrate']:
|
||||
fmt['audio_bitrate'] = int(fmt['bitrate']/1000)
|
||||
source = {
|
||||
'type': 'audio/' + fmt['ext'],
|
||||
'quality_string': audio_quality_string(fmt),
|
||||
}
|
||||
source = {'type': 'audio/' + fmt['ext'],
|
||||
'quality_string': audio_quality_string(fmt)}
|
||||
source.update(fmt)
|
||||
source['mime_codec'] = (source['type'] + '; codecs="'
|
||||
+ source['acodec'] + '"')
|
||||
audio_sources.append(source)
|
||||
# video-only source
|
||||
elif all(fmt[attr] for attr in ('vcodec', 'quality', 'width', 'fps',
|
||||
'file_size')):
|
||||
source['mime_codec'] = source['type'] + '; codecs="' + source['acodec'] + '"'
|
||||
tid = fmt.get('audio_track_id') or 'default'
|
||||
if tid not in audio_by_track:
|
||||
audio_by_track[tid] = {
|
||||
'name': fmt.get('audio_track_name') or 'Default',
|
||||
'is_default': fmt.get('audio_track_is_default', True),
|
||||
'sources': [],
|
||||
}
|
||||
audio_by_track[tid]['sources'].append(source)
|
||||
elif all(fmt[attr] for attr in ('vcodec', 'quality', 'width', 'fps', 'file_size')):
|
||||
if codec_name(fmt['vcodec']) == 'unknown':
|
||||
continue
|
||||
source = {
|
||||
'type': 'video/' + fmt['ext'],
|
||||
'quality_string': short_video_quality_string(fmt),
|
||||
}
|
||||
source = {'type': 'video/' + fmt['ext'],
|
||||
'quality_string': short_video_quality_string(fmt)}
|
||||
source.update(fmt)
|
||||
source['mime_codec'] = (source['type'] + '; codecs="'
|
||||
+ source['vcodec'] + '"')
|
||||
source['mime_codec'] = source['type'] + '; codecs="' + source['vcodec'] + '"'
|
||||
quality = str(fmt['quality']) + 'p' + str(fmt['fps'])
|
||||
if quality in video_only_sources:
|
||||
video_only_sources[quality].append(source)
|
||||
else:
|
||||
video_only_sources[quality] = [source]
|
||||
video_only_sources.setdefault(quality, []).append(source)
|
||||
|
||||
audio_sources.sort(key=lambda source: source['audio_bitrate'])
|
||||
audio_tracks = []
|
||||
default_track_id = 'default'
|
||||
for tid, ti in audio_by_track.items():
|
||||
audio_tracks.append({'id': tid, 'name': ti['name'], 'is_default': ti['is_default']})
|
||||
if ti['is_default']:
|
||||
default_track_id = tid
|
||||
audio_tracks.sort(key=lambda t: (not t['is_default'], t['name']))
|
||||
|
||||
default_audio = audio_by_track.get(default_track_id, {}).get('sources', [])
|
||||
default_audio.sort(key=lambda s: s['audio_bitrate'])
|
||||
uni_sources.sort(key=lambda src: src['quality'])
|
||||
|
||||
webm_audios = [a for a in audio_sources if a['ext'] == 'webm']
|
||||
mp4_audios = [a for a in audio_sources if a['ext'] == 'mp4']
|
||||
webm_audios = [a for a in default_audio if a['ext'] == 'webm']
|
||||
mp4_audios = [a for a in default_audio if a['ext'] == 'mp4']
|
||||
|
||||
for quality_string, sources in video_only_sources.items():
|
||||
# choose an audio source to go with it
|
||||
@@ -166,11 +161,19 @@ def get_video_sources(info, target_resolution):
|
||||
break
|
||||
pair_idx = i
|
||||
|
||||
audio_track_sources = {}
|
||||
for tid, ti in audio_by_track.items():
|
||||
srcs = ti['sources']
|
||||
srcs.sort(key=lambda s: s.get('audio_bitrate', 0))
|
||||
audio_track_sources[tid] = srcs
|
||||
|
||||
return {
|
||||
'uni_sources': uni_sources,
|
||||
'uni_idx': uni_idx,
|
||||
'pair_sources': pair_sources,
|
||||
'pair_idx': pair_idx,
|
||||
'audio_tracks': audio_tracks,
|
||||
'audio_track_sources': audio_track_sources,
|
||||
}
|
||||
|
||||
|
||||
@@ -329,11 +332,8 @@ def get_ordered_music_list_attributes(music_list):
|
||||
|
||||
|
||||
def save_decrypt_cache():
|
||||
try:
|
||||
f = open(os.path.join(settings.data_dir, 'decrypt_function_cache.json'), 'w')
|
||||
except FileNotFoundError:
|
||||
os.makedirs(settings.data_dir)
|
||||
f = open(os.path.join(settings.data_dir, 'decrypt_function_cache.json'), 'w')
|
||||
os.makedirs(settings.data_dir, exist_ok=True)
|
||||
f = open(os.path.join(settings.data_dir, 'decrypt_function_cache.json'), 'w')
|
||||
|
||||
f.write(json.dumps({'version': 1, 'decrypt_cache':decrypt_cache}, indent=4, sort_keys=True))
|
||||
f.close()
|
||||
@@ -426,8 +426,115 @@ def extract_info(video_id, use_invidious, playlist_id=None, index=None):
|
||||
'captionTracks', default=[])
|
||||
info['_android_caption_tracks'] = android_caption_tracks
|
||||
|
||||
# Save streamingData for multi-audio extraction
|
||||
pr_streaming_data = pr_data.get('streamingData', {})
|
||||
info['_streamingData'] = pr_streaming_data
|
||||
|
||||
yt_data_extract.update_with_new_urls(info, player_response)
|
||||
|
||||
# HLS manifest - try multiple clients in case one is blocked
|
||||
info['hls_manifest_url'] = None
|
||||
info['hls_audio_tracks'] = {}
|
||||
hls_data = None
|
||||
hls_client_used = None
|
||||
for hls_client in ('ios', 'ios_vr', 'android'):
|
||||
try:
|
||||
resp = fetch_player_response(hls_client, video_id) or {}
|
||||
hls_data = json.loads(resp) if isinstance(resp, str) else resp
|
||||
hls_manifest_url = (hls_data.get('streamingData') or {}).get('hlsManifestUrl', '')
|
||||
if hls_manifest_url:
|
||||
hls_client_used = hls_client
|
||||
break
|
||||
except Exception as e:
|
||||
print(f'HLS fetch with {hls_client} failed: {e}')
|
||||
|
||||
if hls_manifest_url:
|
||||
info['hls_manifest_url'] = hls_manifest_url
|
||||
import re as _re
|
||||
from urllib.parse import urljoin
|
||||
hls_manifest = util.fetch_url(hls_manifest_url,
|
||||
headers=(('User-Agent', 'Mozilla/5.0'),),
|
||||
debug_name='hls_manifest').decode('utf-8')
|
||||
|
||||
# Parse EXT-X-MEDIA audio tracks from HLS manifest
|
||||
for line in hls_manifest.split('\n'):
|
||||
if '#EXT-X-MEDIA' not in line or 'TYPE=AUDIO' not in line:
|
||||
continue
|
||||
name_m = _re.search(r'NAME="([^"]+)"', line)
|
||||
lang_m = _re.search(r'LANGUAGE="([^"]+)"', line)
|
||||
default_m = _re.search(r'DEFAULT=(YES|NO)', line)
|
||||
group_m = _re.search(r'GROUP-ID="([^"]+)"', line)
|
||||
uri_m = _re.search(r'URI="([^"]+)"', line)
|
||||
if not uri_m or not lang_m:
|
||||
continue
|
||||
lang = lang_m.group(1)
|
||||
is_default = default_m and default_m.group(1) == 'YES'
|
||||
group = group_m.group(1) if group_m else '0'
|
||||
key = lang
|
||||
absolute_hls_url = urljoin(hls_manifest_url, uri_m.group(1))
|
||||
if key not in info['hls_audio_tracks'] or group > info['hls_audio_tracks'][key].get('group', '0'):
|
||||
info['hls_audio_tracks'][key] = {
|
||||
'name': name_m.group(1) if name_m else lang,
|
||||
'lang': lang,
|
||||
'hls_url': absolute_hls_url,
|
||||
'group': group,
|
||||
'is_default': is_default,
|
||||
}
|
||||
|
||||
# Register HLS audio tracks for proxy access
|
||||
added = 0
|
||||
for lang, track in info['hls_audio_tracks'].items():
|
||||
ck = video_id + '_' + lang
|
||||
from youtube.hls_cache import register_track
|
||||
register_track(ck, track['hls_url'],
|
||||
video_id=video_id, track_id=lang)
|
||||
|
||||
fmt = {
|
||||
'audio_track_id': lang,
|
||||
'audio_track_name': track['name'],
|
||||
'audio_track_is_default': track['is_default'],
|
||||
'itag': 'hls_' + lang,
|
||||
'ext': 'mp4',
|
||||
'audio_bitrate': 128,
|
||||
'bitrate': 128000,
|
||||
'acodec': 'mp4a.40.2',
|
||||
'vcodec': None,
|
||||
'width': None,
|
||||
'height': None,
|
||||
'file_size': None,
|
||||
'audio_sample_rate': 44100,
|
||||
'duration_ms': None,
|
||||
'fps': None,
|
||||
'init_range': {'start': 0, 'end': 0},
|
||||
'index_range': {'start': 0, 'end': 0},
|
||||
'url': '/ytl-api/audio-track?id=' + urllib.parse.quote(ck),
|
||||
's': None,
|
||||
'sp': None,
|
||||
'quality': None,
|
||||
'type': 'audio/mp4',
|
||||
'quality_string': track['name'],
|
||||
'mime_codec': 'audio/mp4; codecs="mp4a.40.2"',
|
||||
'is_hls': True,
|
||||
}
|
||||
info['formats'].append(fmt)
|
||||
added += 1
|
||||
|
||||
if added:
|
||||
print(f"Added {added} HLS audio tracks (via {hls_client_used})")
|
||||
else:
|
||||
print("No HLS manifest available from any client")
|
||||
info['hls_manifest_url'] = None
|
||||
info['hls_audio_tracks'] = {}
|
||||
info['hls_unavailable'] = True
|
||||
|
||||
# Register HLS manifest for proxying
|
||||
if info['hls_manifest_url']:
|
||||
ck = video_id + '_video'
|
||||
from youtube.hls_cache import register_track
|
||||
register_track(ck, info['hls_manifest_url'], video_id=video_id, track_id='video')
|
||||
# Use proxy URL instead of direct Google Video URL
|
||||
info['hls_manifest_url'] = '/ytl-api/hls-manifest?id=' + urllib.parse.quote(ck)
|
||||
|
||||
# Fallback to 'ios' if no valid URLs are found
|
||||
if not info.get('formats') or info.get('player_urls_missing'):
|
||||
print(f"No URLs found in '{primary_client}', attempting with '{fallback_client}'.")
|
||||
@@ -559,6 +666,339 @@ def format_bytes(bytes):
|
||||
return '%.2f%s' % (converted, suffix)
|
||||
|
||||
|
||||
@yt_app.route('/ytl-api/audio-track-proxy')
|
||||
def audio_track_proxy():
|
||||
"""Proxy for DASH audio tracks to avoid throttling."""
|
||||
cache_key = request.args.get('id', '')
|
||||
audio_url = request.args.get('url', '')
|
||||
|
||||
if not audio_url:
|
||||
flask.abort(400, 'Missing URL')
|
||||
|
||||
try:
|
||||
headers = (
|
||||
('User-Agent', 'Mozilla/5.0'),
|
||||
('Accept', '*/*'),
|
||||
)
|
||||
content = util.fetch_url(audio_url, headers=headers,
|
||||
debug_name='audio_dash', report_text=None)
|
||||
return flask.Response(content, mimetype='audio/mp4',
|
||||
headers={'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=3600'})
|
||||
except Exception as e:
|
||||
flask.abort(502, f'Audio fetch failed: {e}')
|
||||
|
||||
|
||||
@yt_app.route('/ytl-api/audio-track')
|
||||
def get_audio_track():
|
||||
"""Proxy HLS audio/video: playlist or individual segment."""
|
||||
from youtube.hls_cache import get_hls_url, _tracks
|
||||
|
||||
cache_key = request.args.get('id', '')
|
||||
seg_url = request.args.get('seg', '')
|
||||
playlist_url = request.args.get('url', '')
|
||||
|
||||
# Handle playlist/manifest URL (used for audio track playlists)
|
||||
if playlist_url:
|
||||
# Unwrap if double-proxied
|
||||
if '/ytl-api/audio-track' in playlist_url:
|
||||
import urllib.parse as _up
|
||||
parsed = _up.parse_qs(_up.urlparse(playlist_url).query)
|
||||
if 'url' in parsed:
|
||||
playlist_url = parsed['url'][0]
|
||||
|
||||
try:
|
||||
playlist = util.fetch_url(playlist_url,
|
||||
headers=(('User-Agent', 'Mozilla/5.0'),),
|
||||
debug_name='audio_playlist').decode('utf-8')
|
||||
|
||||
# Rewrite segment URLs
|
||||
import re as _re
|
||||
from urllib.parse import urljoin
|
||||
base_url = request.url_root.rstrip('/')
|
||||
playlist_base = playlist_url.rsplit('/', 1)[0] + '/'
|
||||
|
||||
playlist_lines = []
|
||||
for line in playlist.split('\n'):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
playlist_lines.append(line)
|
||||
continue
|
||||
|
||||
# Resolve and proxy segment URL
|
||||
seg = line if line.startswith('http') else urljoin(playlist_base, line)
|
||||
# Always use &seg= parameter, never &url= for segments
|
||||
playlist_lines.append(
|
||||
base_url + '/ytl-api/audio-track?id='
|
||||
+ urllib.parse.quote(cache_key)
|
||||
+ '&seg=' + urllib.parse.quote(seg, safe='')
|
||||
)
|
||||
|
||||
playlist = '\n'.join(playlist_lines)
|
||||
|
||||
return flask.Response(playlist, mimetype='application/vnd.apple.mpegurl',
|
||||
headers={'Access-Control-Allow-Origin': '*'})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
flask.abort(502, f'Playlist fetch failed: {e}')
|
||||
|
||||
# Handle individual segment or nested playlist
|
||||
if seg_url:
|
||||
# Check if seg_url is already a proxied URL
|
||||
if '/ytl-api/audio-track' in seg_url:
|
||||
import urllib.parse as _up
|
||||
parsed = _up.parse_qs(_up.urlparse(seg_url).query)
|
||||
if 'seg' in parsed:
|
||||
seg_url = parsed['seg'][0]
|
||||
elif 'url' in parsed:
|
||||
seg_url = parsed['url'][0]
|
||||
|
||||
# Check if this is a nested playlist (m3u8) that needs rewriting
|
||||
# Playlists END with .m3u8 (optionally followed by query params)
|
||||
# Segments may contain /index.m3u8/ in their path but end with .ts or similar
|
||||
url_path = urllib.parse.urlparse(seg_url).path
|
||||
|
||||
# Only treat as playlist if path ends with .m3u8
|
||||
# Don't use 'in' check because segments can have /index.m3u8/ in their path
|
||||
is_playlist = url_path.endswith('.m3u8')
|
||||
|
||||
if is_playlist:
|
||||
# This is a variant playlist - fetch and rewrite it
|
||||
try:
|
||||
raw_content = util.fetch_url(seg_url,
|
||||
headers=(('User-Agent', 'Mozilla/5.0'),),
|
||||
debug_name='nested_playlist')
|
||||
|
||||
# Check if this is actually binary data (segment) misidentified as playlist
|
||||
try:
|
||||
playlist = raw_content.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
is_playlist = False # Fall through to segment handler
|
||||
|
||||
if is_playlist:
|
||||
# Rewrite segment URLs in this playlist
|
||||
from urllib.parse import urljoin
|
||||
import re as _re
|
||||
base_url = request.url_root.rstrip('/')
|
||||
playlist_base = seg_url.rsplit('/', 1)[0] + '/'
|
||||
|
||||
def proxy_url(url):
|
||||
"""Rewrite a single URL to go through the proxy"""
|
||||
if not url or url.startswith('/ytl-api/'):
|
||||
return url
|
||||
if not url.startswith('http://') and not url.startswith('https://'):
|
||||
url = urljoin(playlist_base, url)
|
||||
return (base_url + '/ytl-api/audio-track?id='
|
||||
+ urllib.parse.quote(cache_key)
|
||||
+ '&seg=' + urllib.parse.quote(url, safe=''))
|
||||
|
||||
playlist_lines = []
|
||||
for line in playlist.split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
playlist_lines.append(line)
|
||||
continue
|
||||
|
||||
# Handle tags with URI attributes (EXT-X-MAP, EXT-X-KEY, etc.)
|
||||
if line.startswith('#') and 'URI=' in line:
|
||||
def rewrite_uri_attr(match):
|
||||
uri = match.group(1)
|
||||
return 'URI="' + proxy_url(uri) + '"'
|
||||
line = _re.sub(r'URI="([^"]+)"', rewrite_uri_attr, line)
|
||||
playlist_lines.append(line)
|
||||
elif line.startswith('#'):
|
||||
# Other tags pass through unchanged
|
||||
playlist_lines.append(line)
|
||||
else:
|
||||
# This is a segment URL line
|
||||
seg = line if line.startswith('http') else urljoin(playlist_base, line)
|
||||
playlist_lines.append(proxy_url(seg))
|
||||
|
||||
playlist = '\n'.join(playlist_lines)
|
||||
|
||||
return flask.Response(playlist, mimetype='application/vnd.apple.mpegurl',
|
||||
headers={'Access-Control-Allow-Origin': '*'})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
flask.abort(502, f'Nested playlist fetch failed: {e}')
|
||||
|
||||
# This is an actual segment - fetch and serve it
|
||||
try:
|
||||
headers = (
|
||||
('User-Agent', 'Mozilla/5.0'),
|
||||
('Accept', '*/*'),
|
||||
)
|
||||
content = util.fetch_url(seg_url, headers=headers,
|
||||
debug_name='hls_seg', report_text=None)
|
||||
|
||||
# Determine content type based on URL or content
|
||||
# HLS segments are usually MPEG-TS (.ts) but can be MP4 (.mp4, .m4s)
|
||||
if '.mp4' in seg_url or '.m4s' in seg_url or seg_url.lower().endswith('.mp4'):
|
||||
content_type = 'video/mp4'
|
||||
elif '.webm' in seg_url or seg_url.lower().endswith('.webm'):
|
||||
content_type = 'video/webm'
|
||||
else:
|
||||
# Default to MPEG-TS for HLS
|
||||
content_type = 'video/mp2t'
|
||||
|
||||
return flask.Response(content, mimetype=content_type,
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Range, Content-Type',
|
||||
'Cache-Control': 'max-age=3600',
|
||||
'Content-Type': content_type,
|
||||
})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
flask.abort(502, f'Segment fetch failed: {e}')
|
||||
|
||||
# Legacy: Proxy the HLS playlist for audio tracks (using get_hls_url)
|
||||
hls_url = get_hls_url(cache_key)
|
||||
if not hls_url:
|
||||
flask.abort(404, 'Audio track not found')
|
||||
|
||||
try:
|
||||
playlist = util.fetch_url(hls_url,
|
||||
headers=(('User-Agent', 'Mozilla/5.0'),),
|
||||
debug_name='audio_hls_playlist').decode('utf-8')
|
||||
|
||||
# Rewrite segment URLs to go through our proxy endpoint
|
||||
import re as _re
|
||||
from urllib.parse import urljoin
|
||||
hls_base_url = hls_url.rsplit('/', 1)[0] + '/'
|
||||
|
||||
def make_proxy_url(segment_url):
|
||||
if segment_url.startswith('/ytl-api/audio-track'):
|
||||
return segment_url
|
||||
base_url = request.url_root.rstrip('/')
|
||||
return (base_url + '/ytl-api/audio-track?id='
|
||||
+ urllib.parse.quote(cache_key)
|
||||
+ '&seg=' + urllib.parse.quote(segment_url))
|
||||
|
||||
playlist_lines = []
|
||||
for line in playlist.split('\n'):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
playlist_lines.append(line)
|
||||
continue
|
||||
|
||||
if line.startswith('http://') or line.startswith('https://'):
|
||||
segment_url = line
|
||||
else:
|
||||
segment_url = urljoin(hls_base_url, line)
|
||||
|
||||
playlist_lines.append(make_proxy_url(segment_url))
|
||||
|
||||
playlist = '\n'.join(playlist_lines)
|
||||
|
||||
return flask.Response(playlist, mimetype='application/vnd.apple.mpegurl',
|
||||
headers={'Access-Control-Allow-Origin': '*'})
|
||||
except Exception as e:
|
||||
flask.abort(502, f'Playlist fetch failed: {e}')
|
||||
|
||||
|
||||
@yt_app.route('/ytl-api/hls-manifest')
|
||||
def get_hls_manifest():
|
||||
"""Proxy HLS video manifest, rewriting ALL URLs including audio tracks."""
|
||||
from youtube.hls_cache import get_hls_url
|
||||
|
||||
cache_key = request.args.get('id', '')
|
||||
is_audio = '_audio_' in cache_key or cache_key.endswith('_audio')
|
||||
print(f'[hls-manifest] Request: id={cache_key[:40] if cache_key else ""}... (audio={is_audio})')
|
||||
|
||||
hls_url = get_hls_url(cache_key)
|
||||
print(f'[hls-manifest] HLS URL: {hls_url[:80] if hls_url else None}...')
|
||||
if not hls_url:
|
||||
flask.abort(404, 'HLS manifest not found')
|
||||
|
||||
try:
|
||||
print(f'[hls-manifest] Fetching HLS manifest...')
|
||||
manifest = util.fetch_url(hls_url,
|
||||
headers=(('User-Agent', 'Mozilla/5.0'),),
|
||||
debug_name='hls_manifest').decode('utf-8')
|
||||
print(f'[hls-manifest] Successfully fetched manifest ({len(manifest)} bytes)')
|
||||
|
||||
# Rewrite all URLs in the manifest to go through our proxy
|
||||
import re as _re
|
||||
from urllib.parse import urljoin
|
||||
|
||||
# Get the base URL for resolving relative URLs
|
||||
hls_base_url = hls_url.rsplit('/', 1)[0] + '/'
|
||||
base_url = request.url_root.rstrip('/')
|
||||
|
||||
# Rewrite URLs - handle both segment URLs and audio track URIs
|
||||
def rewrite_url(url, is_audio_track=False):
|
||||
if not url or url.startswith('/ytl-api/'):
|
||||
return url
|
||||
|
||||
# Resolve relative URLs
|
||||
if not url.startswith('http://') and not url.startswith('https://'):
|
||||
url = urljoin(hls_base_url, url)
|
||||
|
||||
if is_audio_track:
|
||||
# Audio track playlist - proxy through audio-track endpoint
|
||||
return (base_url + '/ytl-api/audio-track?id='
|
||||
+ urllib.parse.quote(cache_key)
|
||||
+ '&url=' + urllib.parse.quote(url, safe=''))
|
||||
else:
|
||||
# Video segment or variant playlist - proxy through audio-track endpoint
|
||||
return (base_url + '/ytl-api/audio-track?id='
|
||||
+ urllib.parse.quote(cache_key)
|
||||
+ '&seg=' + urllib.parse.quote(url, safe=''))
|
||||
|
||||
# Parse and rewrite the manifest
|
||||
manifest_lines = []
|
||||
rewritten_count = 0
|
||||
for line in manifest.split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
manifest_lines.append(line)
|
||||
continue
|
||||
|
||||
# Handle EXT-X-MEDIA tags with URI (audio tracks)
|
||||
if line.startswith('#EXT-X-MEDIA:') and 'URI=' in line:
|
||||
# Extract and rewrite the URI attribute
|
||||
def rewrite_media_uri(match):
|
||||
nonlocal rewritten_count
|
||||
uri = match.group(1)
|
||||
rewritten_count += 1
|
||||
return 'URI="' + rewrite_url(uri, is_audio_track=True) + '"'
|
||||
line = _re.sub(r'URI="([^"]+)"', rewrite_media_uri, line)
|
||||
manifest_lines.append(line)
|
||||
elif line.startswith('#'):
|
||||
# Other tags pass through
|
||||
manifest_lines.append(line)
|
||||
else:
|
||||
# This is a URL (segment or variant playlist)
|
||||
if line.startswith('http://') or line.startswith('https://'):
|
||||
url = line
|
||||
else:
|
||||
url = urljoin(hls_base_url, line)
|
||||
rewritten_count += 1
|
||||
manifest_lines.append(rewrite_url(url))
|
||||
|
||||
manifest = '\n'.join(manifest_lines)
|
||||
print(f'[hls-manifest] Rewrote manifest with {len(manifest_lines)} lines, {rewritten_count} URLs rewritten')
|
||||
|
||||
return flask.Response(manifest, mimetype='application/vnd.apple.mpegurl',
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Range, Content-Type',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
})
|
||||
except Exception as e:
|
||||
print(f'[hls-manifest] Error: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
flask.abort(502, f'Manifest fetch failed: {e}')
|
||||
|
||||
|
||||
@yt_app.route('/ytl-api/storyboard.vtt')
|
||||
def get_storyboard_vtt():
|
||||
"""
|
||||
@@ -734,47 +1174,50 @@ def get_watch_page(video_id=None):
|
||||
if (settings.route_tor == 2) or info['tor_bypass_used']:
|
||||
target_resolution = 240
|
||||
else:
|
||||
target_resolution = settings.default_resolution
|
||||
res = settings.default_resolution
|
||||
target_resolution = 1080 if res == 'auto' else int(res)
|
||||
|
||||
source_info = get_video_sources(info, target_resolution)
|
||||
uni_sources = source_info['uni_sources']
|
||||
pair_sources = source_info['pair_sources']
|
||||
uni_idx, pair_idx = source_info['uni_idx'], source_info['pair_idx']
|
||||
# Get video sources for no-JS fallback and DASH (av-merge) fallback
|
||||
video_sources = get_video_sources(info, target_resolution)
|
||||
uni_sources = video_sources['uni_sources']
|
||||
pair_sources = video_sources['pair_sources']
|
||||
pair_idx = video_sources['pair_idx']
|
||||
audio_track_sources = video_sources['audio_track_sources']
|
||||
|
||||
pair_quality = yt_data_extract.deep_get(pair_sources, pair_idx, 'quality')
|
||||
uni_quality = yt_data_extract.deep_get(uni_sources, uni_idx, 'quality')
|
||||
# Build audio tracks list from HLS
|
||||
audio_tracks = []
|
||||
hls_audio_tracks = info.get('hls_audio_tracks', {})
|
||||
hls_manifest_url = info.get('hls_manifest_url')
|
||||
if hls_audio_tracks:
|
||||
# Prefer "original" audio track
|
||||
original_lang = None
|
||||
for lang, track in hls_audio_tracks.items():
|
||||
if 'original' in (track.get('name') or '').lower():
|
||||
original_lang = lang
|
||||
break
|
||||
|
||||
pair_error = abs((pair_quality or 360) - target_resolution)
|
||||
uni_error = abs((uni_quality or 360) - target_resolution)
|
||||
if uni_error == pair_error:
|
||||
# use settings.prefer_uni_sources as a tiebreaker
|
||||
closer_to_target = 'uni' if settings.prefer_uni_sources else 'pair'
|
||||
elif uni_error < pair_error:
|
||||
closer_to_target = 'uni'
|
||||
# Add tracks, preferring original as default
|
||||
for lang, track in hls_audio_tracks.items():
|
||||
is_default = (lang == original_lang) if original_lang else track['is_default']
|
||||
if is_default:
|
||||
audio_tracks.insert(0, {
|
||||
'id': lang,
|
||||
'name': track['name'],
|
||||
'is_default': True,
|
||||
})
|
||||
else:
|
||||
audio_tracks.append({
|
||||
'id': lang,
|
||||
'name': track['name'],
|
||||
'is_default': False,
|
||||
})
|
||||
else:
|
||||
closer_to_target = 'pair'
|
||||
# Fallback: single default audio track
|
||||
audio_tracks = [{'id': 'default', 'name': 'Default', 'is_default': True}]
|
||||
|
||||
if settings.prefer_uni_sources == 2:
|
||||
# Use uni sources unless there's no choice.
|
||||
using_pair_sources = (
|
||||
bool(pair_sources) and (not uni_sources)
|
||||
)
|
||||
else:
|
||||
# Use the pair sources if they're closer to the desired resolution
|
||||
using_pair_sources = (
|
||||
bool(pair_sources)
|
||||
and (not uni_sources or closer_to_target == 'pair')
|
||||
)
|
||||
if using_pair_sources:
|
||||
video_height = pair_sources[pair_idx]['height']
|
||||
video_width = pair_sources[pair_idx]['width']
|
||||
else:
|
||||
video_height = yt_data_extract.deep_get(
|
||||
uni_sources, uni_idx, 'height', default=360
|
||||
)
|
||||
video_width = yt_data_extract.deep_get(
|
||||
uni_sources, uni_idx, 'width', default=640
|
||||
)
|
||||
# Get video dimensions
|
||||
video_height = info.get('height') or 360
|
||||
video_width = info.get('width') or 640
|
||||
|
||||
|
||||
|
||||
@@ -821,7 +1264,14 @@ def get_watch_page(video_id=None):
|
||||
other_downloads = other_downloads,
|
||||
video_info = json.dumps(video_info),
|
||||
hls_formats = info['hls_formats'],
|
||||
hls_manifest_url = hls_manifest_url,
|
||||
audio_tracks = audio_tracks,
|
||||
subtitle_sources = subtitle_sources,
|
||||
uni_sources = uni_sources,
|
||||
pair_sources = pair_sources,
|
||||
pair_idx = pair_idx,
|
||||
hls_unavailable = info.get('hls_unavailable', False),
|
||||
playback_mode = settings.playback_mode,
|
||||
related = info['related_videos'],
|
||||
playlist = info['playlist'],
|
||||
music_list = info['music_list'],
|
||||
@@ -858,16 +1308,20 @@ def get_watch_page(video_id=None):
|
||||
'video_duration': info['duration'],
|
||||
'settings': settings.current_settings_dict,
|
||||
'has_manual_captions': any(s.get('on') for s in subtitle_sources),
|
||||
**source_info,
|
||||
'using_pair_sources': using_pair_sources,
|
||||
'audio_tracks': audio_tracks,
|
||||
'hls_manifest_url': hls_manifest_url,
|
||||
'time_start': time_start,
|
||||
'playlist': info['playlist'],
|
||||
'related': info['related_videos'],
|
||||
'playability_error': info['playability_error'],
|
||||
'hls_unavailable': info.get('hls_unavailable', False),
|
||||
'pair_sources': pair_sources,
|
||||
'pair_idx': pair_idx,
|
||||
'uni_sources': uni_sources,
|
||||
'uni_idx': video_sources['uni_idx'],
|
||||
'using_pair_sources': bool(pair_sources),
|
||||
},
|
||||
font_family = youtube.font_choices[settings.font], # for embed page
|
||||
**source_info,
|
||||
using_pair_sources = using_pair_sources,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,4 +10,4 @@ from .watch_extraction import (extract_watch_info, get_caption_url,
|
||||
update_with_new_urls, requires_decryption,
|
||||
extract_decryption_function, decrypt_signatures, _formats,
|
||||
update_format_with_type_info, extract_hls_formats,
|
||||
extract_watch_info_from_html, captions_available)
|
||||
extract_watch_info_from_html, captions_available, parse_format)
|
||||
|
||||
@@ -241,7 +241,7 @@ def extract_lockup_view_model_info(item, additional_info={}):
|
||||
info['title'] = title_data.get('content', '')
|
||||
|
||||
# Determine type based on contentType
|
||||
if 'PLAYLIST' in content_type:
|
||||
if 'PLAYLIST' in content_type or 'PODCAST' in content_type:
|
||||
info['type'] = 'playlist'
|
||||
info['playlist_type'] = 'playlist'
|
||||
info['id'] = content_id
|
||||
@@ -253,7 +253,7 @@ def extract_lockup_view_model_info(item, additional_info={}):
|
||||
for row in metadata_rows.get('contentMetadataViewModel', {}).get('metadataRows', []):
|
||||
for part in row.get('metadataParts', []):
|
||||
text = part.get('text', {}).get('content', '')
|
||||
if 'video' in text.lower():
|
||||
if 'video' in text.lower() or 'episode' in text.lower():
|
||||
info['video_count'] = extract_int(text)
|
||||
elif 'VIDEO' in content_type:
|
||||
info['type'] = 'video'
|
||||
@@ -276,25 +276,48 @@ def extract_lockup_view_model_info(item, additional_info={}):
|
||||
info['type'] = 'channel'
|
||||
info['id'] = content_id
|
||||
info['approx_subscriber_count'] = None
|
||||
info['video_count'] = None
|
||||
|
||||
# Extract subscriber count and video count from metadata rows
|
||||
metadata_rows = lockup_metadata.get('metadata', {})
|
||||
for row in metadata_rows.get('contentMetadataViewModel', {}).get('metadataRows', []):
|
||||
for part in row.get('metadataParts', []):
|
||||
text = part.get('text', {}).get('content', '')
|
||||
if 'subscriber' in text.lower():
|
||||
info['approx_subscriber_count'] = extract_approx_int(text)
|
||||
elif 'video' in text.lower():
|
||||
info['video_count'] = extract_int(text)
|
||||
else:
|
||||
info['type'] = 'unsupported'
|
||||
return info
|
||||
|
||||
# Extract thumbnail from contentImage
|
||||
content_image = item.get('contentImage', {})
|
||||
collection_thumb = content_image.get('collectionThumbnailViewModel', {})
|
||||
primary_thumb = collection_thumb.get('primaryThumbnail', {})
|
||||
thumb_vm = primary_thumb.get('thumbnailViewModel', {})
|
||||
image_sources = thumb_vm.get('image', {}).get('sources', [])
|
||||
if image_sources:
|
||||
info['thumbnail'] = image_sources[0].get('url', '')
|
||||
else:
|
||||
info['thumbnail'] = ''
|
||||
info['thumbnail'] = normalize_url(multi_deep_get(content_image,
|
||||
# playlists with collection thumbnail
|
||||
['collectionThumbnailViewModel', 'primaryThumbnail', 'thumbnailViewModel', 'image', 'sources', 0, 'url'],
|
||||
# single thumbnail (some playlists, videos)
|
||||
['thumbnailViewModel', 'image', 'sources', 0, 'url'],
|
||||
)) or ''
|
||||
|
||||
# Extract video/episode count from thumbnail overlay badges
|
||||
# (podcasts and some playlists put the count here instead of metadata rows)
|
||||
thumb_vm = multi_deep_get(content_image,
|
||||
['collectionThumbnailViewModel', 'primaryThumbnail', 'thumbnailViewModel'],
|
||||
['thumbnailViewModel'],
|
||||
) or {}
|
||||
for overlay in thumb_vm.get('overlays', []):
|
||||
for badge in deep_get(overlay, 'thumbnailOverlayBadgeViewModel', 'thumbnailBadges', default=[]):
|
||||
badge_text = deep_get(badge, 'thumbnailBadgeViewModel', 'text', default='')
|
||||
if badge_text and not info.get('video_count'):
|
||||
conservative_update(info, 'video_count', extract_int(badge_text))
|
||||
|
||||
# Extract author info if available
|
||||
info['author'] = None
|
||||
info['author_id'] = None
|
||||
info['author_url'] = None
|
||||
info['description'] = None
|
||||
info['badges'] = []
|
||||
|
||||
# Try to get first video ID from inline player data
|
||||
item_playback = item.get('itemPlayback', {})
|
||||
@@ -309,6 +332,84 @@ def extract_lockup_view_model_info(item, additional_info={}):
|
||||
return info
|
||||
|
||||
|
||||
def extract_shorts_lockup_view_model_info(item, additional_info={}):
|
||||
"""Extract info from shortsLockupViewModel format (YouTube Shorts)"""
|
||||
info = {'error': None, 'type': 'video'}
|
||||
|
||||
# Video ID from reelWatchEndpoint or entityId
|
||||
info['id'] = deep_get(item,
|
||||
'onTap', 'innertubeCommand', 'reelWatchEndpoint', 'videoId')
|
||||
if not info['id']:
|
||||
entity_id = item.get('entityId', '')
|
||||
if entity_id.startswith('shorts-shelf-item-'):
|
||||
info['id'] = entity_id[len('shorts-shelf-item-'):]
|
||||
|
||||
# Thumbnail
|
||||
info['thumbnail'] = normalize_url(deep_get(item,
|
||||
'onTap', 'innertubeCommand', 'reelWatchEndpoint',
|
||||
'thumbnail', 'thumbnails', 0, 'url'))
|
||||
|
||||
# Parse title and views from accessibilityText
|
||||
# Format: "Title, N views - play Short"
|
||||
acc_text = item.get('accessibilityText', '')
|
||||
info['title'] = ''
|
||||
info['view_count'] = None
|
||||
info['approx_view_count'] = None
|
||||
if acc_text:
|
||||
# Remove trailing " - play Short"
|
||||
cleaned = re.sub(r'\s*-\s*play Short$', '', acc_text)
|
||||
# Split on last comma+views pattern to separate title from view count
|
||||
match = re.match(r'^(.*?),\s*([\d,.]+\s*(?:thousand|million|billion|)\s*views?)$',
|
||||
cleaned, re.IGNORECASE)
|
||||
if match:
|
||||
info['title'] = match.group(1).strip()
|
||||
view_text = match.group(2)
|
||||
info['view_count'] = extract_int(view_text)
|
||||
# Convert "7.1 thousand" -> "7.1 K" for display
|
||||
suffix_map = {'thousand': 'K', 'million': 'M', 'billion': 'B'}
|
||||
suffix_match = re.search(r'([\d,.]+)\s*(thousand|million|billion)?', view_text, re.IGNORECASE)
|
||||
if suffix_match:
|
||||
num = suffix_match.group(1)
|
||||
word = suffix_match.group(2)
|
||||
if word:
|
||||
info['approx_view_count'] = num + ' ' + suffix_map[word.lower()]
|
||||
else:
|
||||
info['approx_view_count'] = '{:,}'.format(int(num.replace(',', ''))) if num.isdigit() or num.replace(',','').isdigit() else num
|
||||
else:
|
||||
info['approx_view_count'] = extract_approx_int(view_text)
|
||||
else:
|
||||
# Fallback: try "N views" at end
|
||||
match2 = re.match(r'^(.*?),\s*(.+views?)$', cleaned, re.IGNORECASE)
|
||||
if match2:
|
||||
info['title'] = match2.group(1).strip()
|
||||
info['approx_view_count'] = extract_approx_int(match2.group(2))
|
||||
else:
|
||||
info['title'] = cleaned
|
||||
|
||||
# Overlay text (usually has the title too)
|
||||
overlay_metadata = deep_get(item, 'overlayMetadata',
|
||||
'secondaryText', 'content')
|
||||
if overlay_metadata and not info['approx_view_count']:
|
||||
info['approx_view_count'] = extract_approx_int(overlay_metadata)
|
||||
|
||||
primary_text = deep_get(item, 'overlayMetadata',
|
||||
'primaryText', 'content')
|
||||
if primary_text and not info['title']:
|
||||
info['title'] = primary_text
|
||||
|
||||
info['duration'] = ''
|
||||
info['time_published'] = None
|
||||
info['description'] = None
|
||||
info['badges'] = []
|
||||
info['author'] = None
|
||||
info['author_id'] = None
|
||||
info['author_url'] = None
|
||||
info['index'] = None
|
||||
|
||||
info.update(additional_info)
|
||||
return info
|
||||
|
||||
|
||||
def extract_item_info(item, additional_info={}):
|
||||
if not item:
|
||||
return {'error': 'No item given'}
|
||||
@@ -330,6 +431,10 @@ def extract_item_info(item, additional_info={}):
|
||||
if type == 'lockupViewModel':
|
||||
return extract_lockup_view_model_info(item, additional_info)
|
||||
|
||||
# Handle shortsLockupViewModel format (YouTube Shorts)
|
||||
if type == 'shortsLockupViewModel':
|
||||
return extract_shorts_lockup_view_model_info(item, additional_info)
|
||||
|
||||
# type looks like e.g. 'compactVideoRenderer' or 'gridVideoRenderer'
|
||||
# camelCase split, https://stackoverflow.com/a/37697078
|
||||
type_parts = [s.lower() for s in re.sub(r'([A-Z][a-z]+)', r' \1', type).split()]
|
||||
@@ -463,6 +568,13 @@ def extract_item_info(item, additional_info={}):
|
||||
elif primary_type == 'channel':
|
||||
info['id'] = item.get('channelId')
|
||||
info['approx_subscriber_count'] = extract_approx_int(item.get('subscriberCountText'))
|
||||
# YouTube sometimes puts the handle (@name) in subscriberCountText
|
||||
# instead of the actual count. Fall back to accessibility data.
|
||||
if not info['approx_subscriber_count']:
|
||||
acc_label = deep_get(item, 'subscriberCountText',
|
||||
'accessibility', 'accessibilityData', 'label', default='')
|
||||
if 'subscriber' in acc_label.lower():
|
||||
info['approx_subscriber_count'] = extract_approx_int(acc_label)
|
||||
elif primary_type == 'show':
|
||||
info['id'] = deep_get(item, 'navigationEndpoint', 'watchEndpoint', 'playlistId')
|
||||
info['first_video_id'] = deep_get(item, 'navigationEndpoint',
|
||||
@@ -531,6 +643,7 @@ _item_types = {
|
||||
|
||||
# New viewModel format (YouTube 2024+)
|
||||
'lockupViewModel',
|
||||
'shortsLockupViewModel',
|
||||
}
|
||||
|
||||
def _traverse_browse_renderer(renderer):
|
||||
|
||||
@@ -218,40 +218,100 @@ def extract_playlist_metadata(polymer_json):
|
||||
return {'error': err}
|
||||
|
||||
metadata = {'error': None}
|
||||
header = deep_get(response, 'header', 'playlistHeaderRenderer', default={})
|
||||
metadata['title'] = extract_str(header.get('title'))
|
||||
metadata['title'] = None
|
||||
metadata['first_video_id'] = None
|
||||
metadata['thumbnail'] = None
|
||||
metadata['video_count'] = None
|
||||
metadata['description'] = ''
|
||||
metadata['author'] = None
|
||||
metadata['author_id'] = None
|
||||
metadata['author_url'] = None
|
||||
metadata['view_count'] = None
|
||||
metadata['like_count'] = None
|
||||
metadata['time_published'] = None
|
||||
|
||||
header = deep_get(response, 'header', 'playlistHeaderRenderer', default={})
|
||||
|
||||
if header:
|
||||
# Classic playlistHeaderRenderer format
|
||||
metadata['title'] = extract_str(header.get('title'))
|
||||
metadata['first_video_id'] = deep_get(header, 'playEndpoint', 'watchEndpoint', 'videoId')
|
||||
first_id = re.search(r'([a-z_\-]{11})', deep_get(header,
|
||||
'thumbnail', 'thumbnails', 0, 'url', default=''))
|
||||
if first_id:
|
||||
conservative_update(metadata, 'first_video_id', first_id.group(1))
|
||||
|
||||
metadata['video_count'] = extract_int(header.get('numVideosText'))
|
||||
metadata['description'] = extract_str(header.get('descriptionText'), default='')
|
||||
metadata['author'] = extract_str(header.get('ownerText'))
|
||||
metadata['author_id'] = multi_deep_get(header,
|
||||
['ownerText', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', 'browseId'],
|
||||
['ownerEndpoint', 'browseEndpoint', 'browseId'])
|
||||
metadata['view_count'] = extract_int(header.get('viewCountText'))
|
||||
metadata['like_count'] = extract_int(header.get('likesCountWithoutLikeText'))
|
||||
for stat in header.get('stats', ()):
|
||||
text = extract_str(stat)
|
||||
if 'videos' in text or 'episodes' in text:
|
||||
conservative_update(metadata, 'video_count', extract_int(text))
|
||||
elif 'views' in text:
|
||||
conservative_update(metadata, 'view_count', extract_int(text))
|
||||
elif 'updated' in text:
|
||||
metadata['time_published'] = extract_date(text)
|
||||
else:
|
||||
# New pageHeaderRenderer format (YouTube 2024+)
|
||||
page_header = deep_get(response, 'header', 'pageHeaderRenderer', default={})
|
||||
metadata['title'] = page_header.get('pageTitle')
|
||||
view_model = deep_get(page_header, 'content', 'pageHeaderViewModel', default={})
|
||||
|
||||
# Extract title from viewModel if not found
|
||||
if not metadata['title']:
|
||||
metadata['title'] = deep_get(view_model,
|
||||
'title', 'dynamicTextViewModel', 'text', 'content')
|
||||
|
||||
# Extract metadata from rows (author, video count, views, etc.)
|
||||
meta_rows = deep_get(view_model,
|
||||
'metadata', 'contentMetadataViewModel', 'metadataRows', default=[])
|
||||
for row in meta_rows:
|
||||
for part in row.get('metadataParts', []):
|
||||
text_content = deep_get(part, 'text', 'content', default='')
|
||||
# Author from avatarStack
|
||||
avatar_stack = deep_get(part, 'avatarStack', 'avatarStackViewModel', default={})
|
||||
if avatar_stack:
|
||||
author_text = deep_get(avatar_stack, 'text', 'content')
|
||||
if author_text:
|
||||
metadata['author'] = author_text
|
||||
# Extract author_id from commandRuns
|
||||
for run in deep_get(avatar_stack, 'text', 'commandRuns', default=[]):
|
||||
browse_id = deep_get(run, 'onTap', 'innertubeCommand',
|
||||
'browseEndpoint', 'browseId')
|
||||
if browse_id:
|
||||
metadata['author_id'] = browse_id
|
||||
# Video/episode count
|
||||
if text_content and ('video' in text_content.lower() or 'episode' in text_content.lower()):
|
||||
conservative_update(metadata, 'video_count', extract_int(text_content))
|
||||
# View count
|
||||
elif text_content and 'view' in text_content.lower():
|
||||
conservative_update(metadata, 'view_count', extract_int(text_content))
|
||||
# Last updated
|
||||
elif text_content and 'updated' in text_content.lower():
|
||||
metadata['time_published'] = extract_date(text_content)
|
||||
|
||||
# Extract description from sidebar if available
|
||||
sidebar = deep_get(response, 'sidebar', 'playlistSidebarRenderer', 'items', default=[])
|
||||
for sidebar_item in sidebar:
|
||||
desc = deep_get(sidebar_item, 'playlistSidebarPrimaryInfoRenderer',
|
||||
'description', 'simpleText')
|
||||
if desc:
|
||||
metadata['description'] = desc
|
||||
|
||||
if metadata['author_id']:
|
||||
metadata['author_url'] = 'https://www.youtube.com/channel/' + metadata['author_id']
|
||||
|
||||
metadata['first_video_id'] = deep_get(header, 'playEndpoint', 'watchEndpoint', 'videoId')
|
||||
first_id = re.search(r'([a-z_\-]{11})', deep_get(header,
|
||||
'thumbnail', 'thumbnails', 0, 'url', default=''))
|
||||
if first_id:
|
||||
conservative_update(metadata, 'first_video_id', first_id.group(1))
|
||||
if metadata['first_video_id'] is None:
|
||||
metadata['thumbnail'] = None
|
||||
else:
|
||||
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='')
|
||||
metadata['author'] = extract_str(header.get('ownerText'))
|
||||
metadata['author_id'] = multi_deep_get(header,
|
||||
['ownerText', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', 'browseId'],
|
||||
['ownerEndpoint', 'browseEndpoint', 'browseId'])
|
||||
if metadata['author_id']:
|
||||
metadata['author_url'] = 'https://www.youtube.com/channel/' + metadata['author_id']
|
||||
else:
|
||||
metadata['author_url'] = None
|
||||
metadata['view_count'] = extract_int(header.get('viewCountText'))
|
||||
metadata['like_count'] = extract_int(header.get('likesCountWithoutLikeText'))
|
||||
for stat in header.get('stats', ()):
|
||||
text = extract_str(stat)
|
||||
if 'videos' in text:
|
||||
conservative_update(metadata, 'video_count', extract_int(text))
|
||||
elif 'views' in text:
|
||||
conservative_update(metadata, 'view_count', extract_int(text))
|
||||
elif 'updated' in text:
|
||||
metadata['time_published'] = extract_date(text)
|
||||
|
||||
microformat = deep_get(response, 'microformat', 'microformatDataRenderer',
|
||||
default={})
|
||||
conservative_update(
|
||||
|
||||
@@ -473,13 +473,22 @@ def _extract_formats(info, player_response):
|
||||
itag = yt_fmt.get('itag')
|
||||
|
||||
# Translated audio track
|
||||
# Example: https://www.youtube.com/watch?v=gF9kkB0UWYQ
|
||||
# Only get the original language for now so a foreign
|
||||
# translation will not be picked just because it comes first
|
||||
if deep_get(yt_fmt, 'audioTrack', 'audioIsDefault') is False:
|
||||
continue
|
||||
# Keep non-default tracks for multi-audio support
|
||||
# (they will be served via local proxy)
|
||||
|
||||
fmt = {}
|
||||
|
||||
# Audio track info
|
||||
audio_track = yt_fmt.get('audioTrack')
|
||||
if audio_track:
|
||||
fmt['audio_track_id'] = audio_track.get('id')
|
||||
fmt['audio_track_name'] = audio_track.get('displayName')
|
||||
fmt['audio_track_is_default'] = audio_track.get('audioIsDefault', True)
|
||||
else:
|
||||
fmt['audio_track_id'] = None
|
||||
fmt['audio_track_name'] = None
|
||||
fmt['audio_track_is_default'] = True
|
||||
|
||||
fmt['itag'] = itag
|
||||
fmt['ext'] = None
|
||||
fmt['audio_bitrate'] = None
|
||||
@@ -532,6 +541,61 @@ def _extract_formats(info, player_response):
|
||||
else:
|
||||
info['ip_address'] = None
|
||||
|
||||
|
||||
def parse_format(yt_fmt):
|
||||
'''Parse a single YouTube format dict into our internal format dict.'''
|
||||
itag = yt_fmt.get('itag')
|
||||
fmt = {}
|
||||
|
||||
audio_track = yt_fmt.get('audioTrack')
|
||||
if audio_track:
|
||||
fmt['audio_track_id'] = audio_track.get('id')
|
||||
fmt['audio_track_name'] = audio_track.get('displayName')
|
||||
fmt['audio_track_is_default'] = audio_track.get('audioIsDefault', True)
|
||||
else:
|
||||
fmt['audio_track_id'] = None
|
||||
fmt['audio_track_name'] = None
|
||||
fmt['audio_track_is_default'] = True
|
||||
|
||||
fmt['itag'] = itag
|
||||
fmt['ext'] = None
|
||||
fmt['audio_bitrate'] = None
|
||||
fmt['bitrate'] = yt_fmt.get('bitrate')
|
||||
fmt['acodec'] = None
|
||||
fmt['vcodec'] = None
|
||||
fmt['width'] = yt_fmt.get('width')
|
||||
fmt['height'] = yt_fmt.get('height')
|
||||
fmt['file_size'] = extract_int(yt_fmt.get('contentLength'))
|
||||
fmt['audio_sample_rate'] = extract_int(yt_fmt.get('audioSampleRate'))
|
||||
fmt['duration_ms'] = yt_fmt.get('approxDurationMs')
|
||||
fmt['fps'] = yt_fmt.get('fps')
|
||||
fmt['init_range'] = yt_fmt.get('initRange')
|
||||
fmt['index_range'] = yt_fmt.get('indexRange')
|
||||
for key in ('init_range', 'index_range'):
|
||||
if fmt[key]:
|
||||
fmt[key]['start'] = int(fmt[key]['start'])
|
||||
fmt[key]['end'] = int(fmt[key]['end'])
|
||||
update_format_with_type_info(fmt, yt_fmt)
|
||||
cipher = dict(urllib.parse.parse_qsl(multi_get(yt_fmt,
|
||||
'cipher', 'signatureCipher', default='')))
|
||||
if cipher:
|
||||
fmt['url'] = cipher.get('url')
|
||||
else:
|
||||
fmt['url'] = yt_fmt.get('url')
|
||||
fmt['s'] = cipher.get('s')
|
||||
fmt['sp'] = cipher.get('sp')
|
||||
|
||||
hardcoded_itag_info = _formats.get(str(itag), {})
|
||||
for key, value in hardcoded_itag_info.items():
|
||||
conservative_update(fmt, key, value)
|
||||
fmt['quality'] = hardcoded_itag_info.get('height')
|
||||
conservative_update(fmt, 'quality',
|
||||
extract_int(yt_fmt.get('quality'), whole_word=False))
|
||||
conservative_update(fmt, 'quality',
|
||||
extract_int(yt_fmt.get('qualityLabel'), whole_word=False))
|
||||
|
||||
return fmt
|
||||
|
||||
hls_regex = re.compile(r'[\w_-]+=(?:"[^"]+"|[^",]+),')
|
||||
def extract_hls_formats(hls_manifest):
|
||||
'''returns hls_formats, err'''
|
||||
|
||||
Reference in New Issue
Block a user