Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
bed14713ad
|
|||
|
06051dd127
|
|||
|
7c64630be1
|
|||
|
1aa344c7b0
|
|||
|
fa7273b328
|
255
.gitignore
vendored
255
.gitignore
vendored
@@ -1,150 +1,145 @@
|
|||||||
# Byte-compiled / optimized / DLL files
|
# =============================================================================
|
||||||
|
# .gitignore - YT Local
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Python / Bytecode
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
.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
|
# Virtual Environments
|
||||||
*.spec
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.nox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
|
|
||||||
# Translations
|
|
||||||
*.mo
|
|
||||||
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
|
||||||
db.sqlite3-journal
|
|
||||||
|
|
||||||
# Flask stuff:
|
|
||||||
instance/
|
|
||||||
.webassets-cache
|
|
||||||
|
|
||||||
# Scrapy stuff:
|
|
||||||
.scrapy
|
|
||||||
|
|
||||||
# Sphinx documentation
|
|
||||||
docs/_build/
|
|
||||||
|
|
||||||
# PyBuilder
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Jupyter Notebook
|
|
||||||
.ipynb_checkpoints
|
|
||||||
|
|
||||||
# IPython
|
|
||||||
profile_default/
|
|
||||||
ipython_config.py
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
.python-version
|
|
||||||
|
|
||||||
# pipenv
|
|
||||||
Pipfile.lock
|
|
||||||
|
|
||||||
# PEP 582
|
|
||||||
__pypackages__/
|
|
||||||
|
|
||||||
# Celery stuff
|
|
||||||
celerybeat-schedule
|
|
||||||
celerybeat.pid
|
|
||||||
|
|
||||||
# SageMath parsed files
|
|
||||||
*.sage.py
|
|
||||||
|
|
||||||
# Environments
|
|
||||||
.env
|
.env
|
||||||
.venv
|
.env.*
|
||||||
env/
|
!.env.example
|
||||||
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env/
|
||||||
venv.bak/
|
*.egg-info/
|
||||||
*venv*
|
.eggs/
|
||||||
|
|
||||||
# Spyder project settings
|
# -----------------------------------------------------------------------------
|
||||||
.spyderproject
|
# IDE / Editors
|
||||||
.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_*
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.DS_Store
|
.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
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
*.orig
|
*.orig
|
||||||
|
*.cache/
|
||||||
|
|||||||
@@ -453,8 +453,7 @@ else:
|
|||||||
print("Running in non-portable mode")
|
print("Running in non-portable mode")
|
||||||
settings_dir = os.path.expanduser(os.path.normpath("~/.yt-local"))
|
settings_dir = os.path.expanduser(os.path.normpath("~/.yt-local"))
|
||||||
data_dir = os.path.expanduser(os.path.normpath("~/.yt-local/data"))
|
data_dir = os.path.expanduser(os.path.normpath("~/.yt-local/data"))
|
||||||
if not os.path.exists(settings_dir):
|
os.makedirs(settings_dir, exist_ok=True)
|
||||||
os.makedirs(settings_dir)
|
|
||||||
|
|
||||||
settings_file_path = os.path.join(settings_dir, 'settings.txt')
|
settings_file_path = os.path.join(settings_dir, 'settings.txt')
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ def video_ids_in_playlist(name):
|
|||||||
|
|
||||||
|
|
||||||
def add_to_playlist(name, video_info_list):
|
def add_to_playlist(name, video_info_list):
|
||||||
if not os.path.exists(playlists_directory):
|
os.makedirs(playlists_directory, exist_ok=True)
|
||||||
os.makedirs(playlists_directory)
|
|
||||||
ids = video_ids_in_playlist(name)
|
ids = video_ids_in_playlist(name)
|
||||||
missing_thumbnails = []
|
missing_thumbnails = []
|
||||||
with open(os.path.join(playlists_directory, name + ".txt"), "a", encoding='utf-8') as file:
|
with open(os.path.join(playlists_directory, name + ".txt"), "a", encoding='utf-8') as file:
|
||||||
|
|||||||
@@ -30,42 +30,58 @@ def playlist_ctoken(playlist_id, offset, include_shorts=True):
|
|||||||
|
|
||||||
def playlist_first_page(playlist_id, report_text="Retrieved playlist",
|
def playlist_first_page(playlist_id, report_text="Retrieved playlist",
|
||||||
use_mobile=False):
|
use_mobile=False):
|
||||||
if use_mobile:
|
# Use innertube API (pbj=1 no longer works for many playlists)
|
||||||
url = 'https://m.youtube.com/playlist?list=' + playlist_id + '&pbj=1'
|
key = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
|
||||||
content = util.fetch_url(
|
url = 'https://www.youtube.com/youtubei/v1/browse?key=' + key
|
||||||
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'))
|
|
||||||
|
|
||||||
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,
|
def get_videos(playlist_id, page, include_shorts=True, use_mobile=False,
|
||||||
report_text='Retrieved playlist'):
|
report_text='Retrieved playlist'):
|
||||||
# mobile requests return 20 videos per page
|
page_size = 100
|
||||||
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
|
|
||||||
|
|
||||||
url = "https://m.youtube.com/playlist?ctoken="
|
key = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
|
||||||
url += playlist_ctoken(playlist_id, (int(page)-1)*page_size,
|
url = 'https://www.youtube.com/youtubei/v1/browse?key=' + key
|
||||||
include_shorts=include_shorts)
|
|
||||||
url += "&pbj=1"
|
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(
|
content = util.fetch_url(
|
||||||
url, headers, report_text=report_text,
|
url, util.desktop_xhr_headers + content_type_header,
|
||||||
debug_name='playlist_videos'
|
data=json.dumps(data),
|
||||||
|
report_text=report_text, debug_name='playlist_videos'
|
||||||
)
|
)
|
||||||
|
|
||||||
info = json.loads(content.decode('utf-8'))
|
info = json.loads(content.decode('utf-8'))
|
||||||
@@ -96,7 +112,7 @@ def get_playlist_page():
|
|||||||
tasks = (
|
tasks = (
|
||||||
gevent.spawn(
|
gevent.spawn(
|
||||||
playlist_first_page, playlist_id,
|
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)
|
gevent.spawn(get_videos, playlist_id, page)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ database_path = os.path.join(settings.data_dir, "subscriptions.sqlite")
|
|||||||
|
|
||||||
|
|
||||||
def open_database():
|
def open_database():
|
||||||
if not os.path.exists(settings.data_dir):
|
os.makedirs(settings.data_dir, exist_ok=True)
|
||||||
os.makedirs(settings.data_dir)
|
|
||||||
connection = sqlite3.connect(database_path, check_same_thread=False)
|
connection = sqlite3.connect(database_path, check_same_thread=False)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -58,7 +58,9 @@
|
|||||||
|
|
||||||
<div class="stats {{'horizontal-stats' if horizontal else 'vertical-stats'}}">
|
<div class="stats {{'horizontal-stats' if horizontal else 'vertical-stats'}}">
|
||||||
{% if info['type'] == 'channel' %}
|
{% 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>
|
<div>{{ info['video_count']|commatize }} videos</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if info.get('time_published') %}
|
{% if info.get('time_published') %}
|
||||||
|
|||||||
@@ -343,8 +343,7 @@ def fetch_url(url, headers=(), timeout=15, report_text=None, data=None,
|
|||||||
and debug_name is not None
|
and debug_name is not None
|
||||||
and content):
|
and content):
|
||||||
save_dir = os.path.join(settings.data_dir, 'debug')
|
save_dir = os.path.join(settings.data_dir, 'debug')
|
||||||
if not os.path.exists(save_dir):
|
os.makedirs(save_dir, exist_ok=True)
|
||||||
os.makedirs(save_dir)
|
|
||||||
|
|
||||||
with open(os.path.join(save_dir, debug_name), 'wb') as f:
|
with open(os.path.join(save_dir, debug_name), 'wb') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
@@ -902,8 +901,7 @@ INNERTUBE_CLIENTS = {
|
|||||||
def get_visitor_data():
|
def get_visitor_data():
|
||||||
visitor_data = None
|
visitor_data = None
|
||||||
visitor_data_cache = os.path.join(settings.data_dir, 'visitorData.txt')
|
visitor_data_cache = os.path.join(settings.data_dir, 'visitorData.txt')
|
||||||
if not os.path.exists(settings.data_dir):
|
os.makedirs(settings.data_dir, exist_ok=True)
|
||||||
os.makedirs(settings.data_dir)
|
|
||||||
if os.path.isfile(visitor_data_cache):
|
if os.path.isfile(visitor_data_cache):
|
||||||
with open(visitor_data_cache, 'r') as file:
|
with open(visitor_data_cache, 'r') as file:
|
||||||
print('Getting visitor_data from cache')
|
print('Getting visitor_data from cache')
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
__version__ = 'v0.4.2'
|
__version__ = 'v0.4.4'
|
||||||
|
|||||||
@@ -329,11 +329,8 @@ def get_ordered_music_list_attributes(music_list):
|
|||||||
|
|
||||||
|
|
||||||
def save_decrypt_cache():
|
def save_decrypt_cache():
|
||||||
try:
|
os.makedirs(settings.data_dir, exist_ok=True)
|
||||||
f = open(os.path.join(settings.data_dir, 'decrypt_function_cache.json'), 'w')
|
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')
|
|
||||||
|
|
||||||
f.write(json.dumps({'version': 1, 'decrypt_cache':decrypt_cache}, indent=4, sort_keys=True))
|
f.write(json.dumps({'version': 1, 'decrypt_cache':decrypt_cache}, indent=4, sort_keys=True))
|
||||||
f.close()
|
f.close()
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ def extract_lockup_view_model_info(item, additional_info={}):
|
|||||||
info['title'] = title_data.get('content', '')
|
info['title'] = title_data.get('content', '')
|
||||||
|
|
||||||
# Determine type based on contentType
|
# Determine type based on contentType
|
||||||
if 'PLAYLIST' in content_type:
|
if 'PLAYLIST' in content_type or 'PODCAST' in content_type:
|
||||||
info['type'] = 'playlist'
|
info['type'] = 'playlist'
|
||||||
info['playlist_type'] = 'playlist'
|
info['playlist_type'] = 'playlist'
|
||||||
info['id'] = content_id
|
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 row in metadata_rows.get('contentMetadataViewModel', {}).get('metadataRows', []):
|
||||||
for part in row.get('metadataParts', []):
|
for part in row.get('metadataParts', []):
|
||||||
text = part.get('text', {}).get('content', '')
|
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)
|
info['video_count'] = extract_int(text)
|
||||||
elif 'VIDEO' in content_type:
|
elif 'VIDEO' in content_type:
|
||||||
info['type'] = 'video'
|
info['type'] = 'video'
|
||||||
@@ -276,25 +276,48 @@ def extract_lockup_view_model_info(item, additional_info={}):
|
|||||||
info['type'] = 'channel'
|
info['type'] = 'channel'
|
||||||
info['id'] = content_id
|
info['id'] = content_id
|
||||||
info['approx_subscriber_count'] = None
|
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:
|
else:
|
||||||
info['type'] = 'unsupported'
|
info['type'] = 'unsupported'
|
||||||
return info
|
return info
|
||||||
|
|
||||||
# Extract thumbnail from contentImage
|
# Extract thumbnail from contentImage
|
||||||
content_image = item.get('contentImage', {})
|
content_image = item.get('contentImage', {})
|
||||||
collection_thumb = content_image.get('collectionThumbnailViewModel', {})
|
info['thumbnail'] = normalize_url(multi_deep_get(content_image,
|
||||||
primary_thumb = collection_thumb.get('primaryThumbnail', {})
|
# playlists with collection thumbnail
|
||||||
thumb_vm = primary_thumb.get('thumbnailViewModel', {})
|
['collectionThumbnailViewModel', 'primaryThumbnail', 'thumbnailViewModel', 'image', 'sources', 0, 'url'],
|
||||||
image_sources = thumb_vm.get('image', {}).get('sources', [])
|
# single thumbnail (some playlists, videos)
|
||||||
if image_sources:
|
['thumbnailViewModel', 'image', 'sources', 0, 'url'],
|
||||||
info['thumbnail'] = image_sources[0].get('url', '')
|
)) or ''
|
||||||
else:
|
|
||||||
info['thumbnail'] = ''
|
# 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
|
# Extract author info if available
|
||||||
info['author'] = None
|
info['author'] = None
|
||||||
info['author_id'] = None
|
info['author_id'] = None
|
||||||
info['author_url'] = None
|
info['author_url'] = None
|
||||||
|
info['description'] = None
|
||||||
|
info['badges'] = []
|
||||||
|
|
||||||
# Try to get first video ID from inline player data
|
# Try to get first video ID from inline player data
|
||||||
item_playback = item.get('itemPlayback', {})
|
item_playback = item.get('itemPlayback', {})
|
||||||
@@ -463,6 +486,13 @@ def extract_item_info(item, additional_info={}):
|
|||||||
elif primary_type == 'channel':
|
elif primary_type == 'channel':
|
||||||
info['id'] = item.get('channelId')
|
info['id'] = item.get('channelId')
|
||||||
info['approx_subscriber_count'] = extract_approx_int(item.get('subscriberCountText'))
|
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':
|
elif primary_type == 'show':
|
||||||
info['id'] = deep_get(item, 'navigationEndpoint', 'watchEndpoint', 'playlistId')
|
info['id'] = deep_get(item, 'navigationEndpoint', 'watchEndpoint', 'playlistId')
|
||||||
info['first_video_id'] = deep_get(item, 'navigationEndpoint',
|
info['first_video_id'] = deep_get(item, 'navigationEndpoint',
|
||||||
|
|||||||
@@ -218,40 +218,100 @@ def extract_playlist_metadata(polymer_json):
|
|||||||
return {'error': err}
|
return {'error': err}
|
||||||
|
|
||||||
metadata = {'error': None}
|
metadata = {'error': None}
|
||||||
header = deep_get(response, 'header', 'playlistHeaderRenderer', default={})
|
metadata['title'] = None
|
||||||
metadata['title'] = extract_str(header.get('title'))
|
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:
|
if metadata['first_video_id'] is None:
|
||||||
metadata['thumbnail'] = None
|
metadata['thumbnail'] = None
|
||||||
else:
|
else:
|
||||||
metadata['thumbnail'] = f"https://i.ytimg.com/vi/{metadata['first_video_id']}/hqdefault.jpg"
|
metadata['thumbnail'] = f"https://i.ytimg.com/vi/{metadata['first_video_id']}/hqdefault.jpg"
|
||||||
|
|
||||||
metadata['video_count'] = extract_int(header.get('numVideosText'))
|
|
||||||
metadata['description'] = extract_str(header.get('descriptionText'), default='')
|
|
||||||
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',
|
microformat = deep_get(response, 'microformat', 'microformatDataRenderer',
|
||||||
default={})
|
default={})
|
||||||
conservative_update(
|
conservative_update(
|
||||||
|
|||||||
Reference in New Issue
Block a user