Merge flask framework and other stuff from master

This commit is contained in:
James Taylor
2019-08-09 22:01:04 -07:00
172 changed files with 54256 additions and 2734 deletions

7
youtube/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
import flask
yt_app = flask.Flask(__name__)
yt_app.url_map.strict_slashes = False
@yt_app.route('/')
def homepage():
return flask.render_template('base.html', title="Youtube local")

View File

@@ -1,5 +1,6 @@
# Contains functions having to do with logging in
from youtube import util, html_common
from youtube import util
from youtube import yt_app
import settings
import urllib
@@ -9,6 +10,9 @@ import http.cookiejar
import io
import os
import flask
from flask import request
try:
with open(os.path.join(settings.data_dir, 'accounts.txt'), 'r', encoding='utf-8') as f:
accounts = json.loads(f.read())
@@ -18,7 +22,7 @@ except FileNotFoundError:
def account_list_data():
'''Returns iterable of (channel_id, account_display_name)'''
return ( (channel_id, account['display_name']) for channel_id, account in accounts.items() )
return [ (channel_id, account['display_name']) for channel_id, account in accounts.items() ]
def save_accounts():
to_save = {channel_id: account for channel_id, account in accounts.items() if account['save']}
@@ -51,91 +55,20 @@ def _add_account(username, password, save, use_tor):
return True
return False
def add_account(env, start_response):
parameters = env['parameters']
if 'save' in parameters and parameters['save'][0] == "on":
save_account = True
@yt_app.route('/login', methods=['POST'])
def add_account():
save_account = request.values.get('save', 'off') == 'on'
use_tor = request.values.get('use_tor', 'off') == 'on'
if _add_account(request.values['username'], request.values['password'], save_account, use_tor ):
return 'Account successfully added'
else:
save_account = False
return 'Failed to add account'
if 'use_tor' in parameters and parameters['use_tor'][0] == "on":
use_tor = True
else:
use_tor = False
if _add_account(parameters['username'][0], parameters['password'][0], save_account, use_tor ):
start_response('200 OK', [('Content-type', 'text/plain'),] )
return b'Account successfully added'
else:
start_response('200 OK', [('Content-type', 'text/plain'),] )
return b'Failed to add account'
def get_account_login_page(env, start_response):
start_response('200 OK', [('Content-type','text/html'),] )
style = '''
main{
display: grid;
grid-template-columns: minmax(0px, 3fr) 640px 40px 500px minmax(0px,2fr);
align-content: start;
grid-row-gap: 40px;
}
main form{
margin-top:20px;
grid-column:2;
display:grid;
justify-items: start;
align-content: start;
grid-row-gap: 10px;
}
#username, #password{
grid-column:2;
width: 250px;
}
#add-account-button{
margin-top:20px;
}
#tor-note{
grid-row:2;
grid-column:2;
background-color: #dddddd;
padding: 10px;
}
'''
page = '''
<form action="''' + util.URL_ORIGIN + '''/login" method="POST">
<div class="form-field">
<label for="username">Username:</label>
<input type="text" id="username" name="username">
</div>
<div class="form-field">
<label for="password">Password:</label>
<input type="password" id="password" name="password">
</div>
<div id="save-account-checkbox">
<input type="checkbox" id="save-account" name="save" checked>
<label for="save-account">Save account info to disk (password will not be saved, only the login cookie)</label>
</div>
<div>
<input type="checkbox" id="use-tor" name="use_tor">
<label for="use-tor">Use Tor when logging in (WARNING: This will lock your Google account under normal circumstances, see note below)</label>
</div>
<input type="submit" value="Add account" id="add-account-button">
</form>
<div id="tor-note"><b>Note on using Tor to log in</b><br>
Using Tor to log in should only be done if the account was created using a proxy/VPN/Tor to begin with and hasn't been logged in using your IP. Otherwise, it's pointless since Google already knows who the account belongs to. When logging into a google account, it must be logged in using an IP address geographically close to the area where the account was created or where it is logged into regularly. If the account was created using an IP address in America and is logged into from an IP in Russia, Google will block the Russian IP from logging in, assume someone knows your password, lock the account, and make you change your password. If creating an account using Tor, you must remember the IP (or geographic region) it was created in, and only log in using that geographic region for the exit node. This can be accomplished by <a href="https://tor.stackexchange.com/questions/733/can-i-exit-from-a-specific-country-or-node">putting the desired IP in the torrc file</a> to force Tor to use that exit node. Using the login cookie to post comments through Tor is perfectly safe, however.
</div>
'''
return html_common.yt_basic_template.substitute(
page_title = "Login",
style = style,
header = html_common.get_header(),
page = page,
).encode('utf-8')
@yt_app.route('/login', methods=['GET'])
def get_account_login_page():
return flask.render_template('login.html')
@@ -229,10 +162,8 @@ def _login(username, password, cookiejar, use_tor):
Taken from youtube-dl
"""
login_page = util.fetch_url(_LOGIN_URL, yt_dl_headers, report_text='Downloaded login page', cookiejar_receive=cookiejar, use_tor=use_tor).decode('utf-8')
'''with open('debug/login_page', 'w', encoding='utf-8') as f:
f.write(login_page)'''
#print(cookiejar.as_lwp_str())
login_page = util.fetch_url(_LOGIN_URL, yt_dl_headers, report_text='Downloaded login page', cookiejar_receive=cookiejar, use_tor=use_tor, debug_name='login_page').decode('utf-8')
if login_page is False:
return
@@ -249,16 +180,14 @@ def _login(username, password, cookiejar, use_tor):
'f.req': json.dumps(f_req),
'flowName': 'GlifWebSignIn',
'flowEntry': 'ServiceLogin',
'bgRequest': '["identifier",""]',
})
headers={
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
'Google-Accounts-XSRF': 1,
}
headers.update(yt_dl_headers)
result = util.fetch_url(url, headers, report_text=note, data=data, cookiejar_send=cookiejar, cookiejar_receive=cookiejar, use_tor=use_tor).decode('utf-8')
#print(cookiejar.as_lwp_str())
'''with open('debug/' + note, 'w', encoding='utf-8') as f:
f.write(result)'''
result = util.fetch_url(url, headers, report_text=note, data=data, cookiejar_send=cookiejar, cookiejar_receive=cookiejar, use_tor=use_tor, debug_name=note).decode('utf-8')
result = re.sub(r'^[^\[]*', '', result)
return json.loads(result)
@@ -387,12 +316,10 @@ def _login(username, password, cookiejar, use_tor):
return False
try:
check_cookie_results = util.fetch_url(check_cookie_url, headers=yt_dl_headers, report_text="Checked cookie", cookiejar_send=cookiejar, cookiejar_receive=cookiejar, use_tor=use_tor).decode('utf-8')
check_cookie_results = util.fetch_url(check_cookie_url, headers=yt_dl_headers, report_text="Checked cookie", cookiejar_send=cookiejar, cookiejar_receive=cookiejar, use_tor=use_tor, debug_name='check_cookie_results').decode('utf-8')
except (urllib.error.URLError, compat_http_client.HTTPException, socket.error) as err:
return False
'''with open('debug/check_cookie_results', 'w', encoding='utf-8') as f:
f.write(check_cookie_results)'''
if 'https://myaccount.google.com/' not in check_cookie_results:
warn('Unable to log in')

View File

@@ -1,7 +1,7 @@
import base64
from youtube import util, yt_data_extract, html_common, subscriptions
from youtube import util, yt_data_extract, local_playlist
from youtube import yt_app
import http_errors
import urllib
import json
from string import Template
@@ -12,11 +12,8 @@ import gevent
import re
import functools
with open("yt_channel_items_template.html", "r") as file:
yt_channel_items_template = Template(file.read())
with open("yt_channel_about_template.html", "r") as file:
yt_channel_about_template = Template(file.read())
import flask
from flask import request
'''continuation = Proto(
Field('optional', 'continuation', 80226972, Proto(
@@ -91,16 +88,10 @@ def get_channel_tab(channel_id, page="1", sort=3, tab='videos', view=1):
url = "https://www.youtube.com/browse_ajax?ctoken=" + ctoken
print("Sending channel tab ajax request")
content = util.fetch_url(url, util.desktop_ua + headers_1)
content = util.fetch_url(url, util.desktop_ua + headers_1, debug_name='channel_tab')
print("Finished recieving channel tab response")
'''with open('debug/channel_debug', 'wb') as f:
f.write(content)'''
info = json.loads(content)
return info
return content
def get_number_of_videos(channel_id):
# Uploads playlist
@@ -110,15 +101,13 @@ def get_number_of_videos(channel_id):
# Sometimes retrieving playlist info fails with 403 for no discernable reason
try:
response = util.fetch_url(url, util.mobile_ua + headers_pbj)
response = util.fetch_url(url, util.mobile_ua + headers_pbj, debug_name='number_of_videos')
except urllib.error.HTTPError as e:
if e.code != 403:
raise
print("Couldn't retrieve number of videos")
return 1000
'''with open('debug/playlist_debug_metadata', 'wb') as f:
f.write(response)'''
response = response.decode('utf-8')
print("Got response for number of videos")
@@ -136,71 +125,20 @@ def get_channel_id(username):
response = util.fetch_url(url, util.mobile_ua + headers_1).decode('utf-8')
return re.search(r'"channel_id":\s*"([a-zA-Z0-9_-]*)"', response).group(1)
def grid_items_html(items, additional_info={}):
result = ''' <nav class="item-grid">\n'''
for item in items:
result += html_common.renderer_html(item, additional_info)
result += '''\n</nav>'''
return result
def get_channel_search_json(channel_id, query, page):
params = proto.string(2, 'search') + proto.string(15, str(page))
params = proto.percent_b64encode(params)
ctoken = proto.string(2, channel_id) + proto.string(3, params) + proto.string(11, query)
ctoken = base64.urlsafe_b64encode(proto.nested(80226972, ctoken)).decode('ascii')
def list_items_html(items, additional_info={}):
result = ''' <nav class="item-list">'''
for item in items:
result += html_common.renderer_html(item, additional_info)
result += '''\n</nav>'''
return result
polymer_json = util.fetch_url("https://www.youtube.com/browse_ajax?ctoken=" + ctoken, util.desktop_ua + headers_1, debug_name='channel_search')
channel_tab_template = Template('''\n<a class="tab page-button"$href_attribute>$tab_name</a>''')
channel_search_template = Template('''
<form class="channel-search" action="$action">
<input type="search" name="query" class="search-box" value="$search_box_value">
<button type="submit" value="Search" class="search-button">Search</button>
</form>''')
return polymer_json
tabs = ('Videos', 'Playlists', 'About')
def channel_tabs_html(channel_id, current_tab, search_box_value=''):
result = ''
for tab_name in tabs:
if tab_name == current_tab:
result += channel_tab_template.substitute(
href_attribute = '',
tab_name = tab_name,
)
else:
result += channel_tab_template.substitute(
href_attribute = ' href="' + util.URL_ORIGIN + '/channel/' + channel_id + '/' + tab_name.lower() + '"',
tab_name = tab_name,
)
result += channel_search_template.substitute(
action = util.URL_ORIGIN + "/channel/" + channel_id + "/search",
search_box_value = html.escape(search_box_value),
)
return result
channel_sort_button_template = Template('''\n<a class="sort-button"$href_attribute>$text</a>''')
sorts = {
"videos": (('1', 'views'), ('2', 'oldest'), ('3', 'newest'),),
"playlists": (('2', 'oldest'), ('3', 'newest'), ('4', 'last video added'),),
}
def channel_sort_buttons_html(channel_id, tab, current_sort):
result = ''
for sort_number, sort_name in sorts[tab]:
if sort_number == str(current_sort):
result += channel_sort_button_template.substitute(
href_attribute='',
text = 'Sorted by ' + sort_name
)
else:
result += channel_sort_button_template.substitute(
href_attribute=' href="' + util.URL_ORIGIN + '/channel/' + channel_id + '/' + tab + '?sort=' + sort_number + '"',
text = 'Sort by ' + sort_name
)
return result
def get_microformat(response):
def extract_info(polymer_json, tab):
response = polymer_json[1]['response']
try:
return response['microformat']['microformatDataRenderer']
microformat = response['microformat']['microformatDataRenderer']
# channel doesn't exist or was terminated
# example terminated channel: https://www.youtube.com/channel/UCnKJeK_r90jDdIuzHXC0Org
@@ -209,227 +147,136 @@ def get_microformat(response):
result = ''
for alert in response['alerts']:
result += alert['alertRenderer']['text']['simpleText'] + '\n'
raise http_errors.Code200(result)
flask.abort(200, result)
elif 'errors' in response['responseContext']:
for error in response['responseContext']['errors']['error']:
if error['code'] == 'INVALID_VALUE' and error['location'] == 'browse_id':
raise http_errors.Error404('This channel does not exist')
flask.abort(404, 'This channel does not exist')
raise
# example channel with no videos: https://www.youtube.com/user/jungleace
def get_grid_items(response):
try:
return response['continuationContents']['gridContinuation']['items']
except KeyError:
try:
contents = response['contents']
except KeyError:
return []
item_section = tab_with_content(contents['twoColumnBrowseResultsRenderer']['tabs'])['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]
try:
return item_section['gridRenderer']['items']
except KeyError:
if "messageRenderer" in item_section:
return []
else:
raise
info = {}
info['current_tab'] = tab
def channel_videos_html(polymer_json, current_page=1, current_sort=3, number_of_videos = 1000, current_query_string=''):
response = polymer_json[1]['response']
microformat = get_microformat(response)
# stuff from microformat (info given by youtube for every page on channel)
info['short_description'] = microformat['description']
info['channel_name'] = microformat['title']
info['avatar'] = microformat['thumbnail']['thumbnails'][0]['url']
channel_url = microformat['urlCanonical'].rstrip('/')
channel_id = channel_url[channel_url.rfind('/')+1:]
if subscriptions.is_subscribed(channel_id):
action_name = 'Unsubscribe'
action = 'unsubscribe'
else:
action_name = 'Subscribe'
action = 'subscribe'
info['channel_id'] = channel_id
info['channel_url'] = 'https://www.youtube.com/channel/' + channel_id
items = get_grid_items(response)
items_html = grid_items_html(items, {'author': microformat['title']})
return yt_channel_items_template.substitute(
header = html_common.get_header(),
channel_title = microformat['title'],
channel_id = channel_id,
channel_tabs = channel_tabs_html(channel_id, 'Videos'),
sort_buttons = channel_sort_buttons_html(channel_id, 'videos', current_sort),
avatar = '/' + microformat['thumbnail']['thumbnails'][0]['url'],
page_title = microformat['title'] + ' - Channel',
items = items_html,
page_buttons = html_common.page_buttons_html(current_page, math.ceil(number_of_videos/30), util.URL_ORIGIN + "/channel/" + channel_id + "/videos", current_query_string),
number_of_results = '{:,}'.format(number_of_videos) + " videos",
action_name = action_name,
action = action,
)
info['items'] = []
def channel_playlists_html(polymer_json, current_sort=3):
response = polymer_json[1]['response']
microformat = get_microformat(response)
channel_url = microformat['urlCanonical'].rstrip('/')
channel_id = channel_url[channel_url.rfind('/')+1:]
# empty channel
if 'contents' not in response and 'continuationContents' not in response:
return info
if subscriptions.is_subscribed(channel_id):
action_name = 'Unsubscribe'
action = 'unsubscribe'
else:
action_name = 'Subscribe'
action = 'subscribe'
items = get_grid_items(response)
items_html = grid_items_html(items, {'author': microformat['title']})
return yt_channel_items_template.substitute(
header = html_common.get_header(),
channel_title = microformat['title'],
channel_id = channel_id,
channel_tabs = channel_tabs_html(channel_id, 'Playlists'),
sort_buttons = channel_sort_buttons_html(channel_id, 'playlists', current_sort),
avatar = '/' + microformat['thumbnail']['thumbnails'][0]['url'],
page_title = microformat['title'] + ' - Channel',
items = items_html,
page_buttons = '',
number_of_results = '',
action_name = action_name,
action = action,
)
# find the tab with content
# example channel where tabs do not have definite index: https://www.youtube.com/channel/UC4gQ8i3FD7YbhOgqUkeQEJg
# TODO: maybe use the 'selected' attribute for this?
if 'continuationContents' not in response:
tab_renderer = None
tab_content = None
for tab_json in response['contents']['twoColumnBrowseResultsRenderer']['tabs']:
try:
tab_renderer = tab_json['tabRenderer']
except KeyError:
tab_renderer = tab_json['expandableTabRenderer']
try:
tab_content = tab_renderer['content']
break
except KeyError:
pass
else: # didn't break
raise Exception("No tabs found with content")
assert tab == tab_renderer['title'].lower()
# Example channel where tabs do not have definite index: https://www.youtube.com/channel/UC4gQ8i3FD7YbhOgqUkeQEJg
def tab_with_content(tabs):
for tab in tabs:
try:
renderer = tab['tabRenderer']
except KeyError:
renderer = tab['expandableTabRenderer']
try:
return renderer['content']
except KeyError:
pass
raise Exception("No tabs found with content")
channel_link_template = Template('''
<li><a href="$url">$text</a></li>''')
stat_template = Template('''
<li>$stat_value</li>''')
def channel_about_page(polymer_json):
microformat = get_microformat(polymer_json[1]['response'])
avatar = '/' + microformat['thumbnail']['thumbnails'][0]['url']
# my goodness...
channel_metadata = tab_with_content(polymer_json[1]['response']['contents']['twoColumnBrowseResultsRenderer']['tabs'])['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]['channelAboutFullMetadataRenderer']
channel_links = ''
for link_json in channel_metadata.get('primaryLinks', ()):
url = link_json['navigationEndpoint']['urlEndpoint']['url']
if url.startswith("/redirect"):
query_string = url[url.find('?')+1: ]
url = urllib.parse.parse_qs(query_string)['q'][0]
channel_links += channel_link_template.substitute(
url = html.escape(url),
text = yt_data_extract.get_plain_text(link_json['title']),
)
stats = ''
for stat_name in ('subscriberCountText', 'joinedDateText', 'viewCountText', 'country'):
try:
stat_value = yt_data_extract.get_plain_text(channel_metadata[stat_name])
except KeyError:
continue
# extract tab-specific info
if tab in ('videos', 'playlists', 'search'): # find the list of items
if 'continuationContents' in response:
try:
items = response['continuationContents']['gridContinuation']['items']
except KeyError:
items = response['continuationContents']['sectionListContinuation']['contents'] # for search
else:
stats += stat_template.substitute(stat_value=stat_value)
contents = tab_content['sectionListRenderer']['contents']
if 'itemSectionRenderer' in contents[0]:
item_section = contents[0]['itemSectionRenderer']['contents'][0]
try:
items = item_section['gridRenderer']['items']
except KeyError:
if "messageRenderer" in item_section:
items = []
else:
raise Exception('gridRenderer missing but messageRenderer not found')
else:
items = contents # for search
# TODO: Fix this URL prefixing shit
additional_info = {'author': info['channel_name'], 'author_url': '/channel/' + channel_id}
info['items'] = [yt_data_extract.renderer_info(renderer, additional_info) for renderer in items]
elif tab == 'about':
channel_metadata = tab_content['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]['channelAboutFullMetadataRenderer']
channel_id = channel_metadata['channelId']
if subscriptions.is_subscribed(channel_id):
action_name = 'Unsubscribe'
action = 'unsubscribe'
info['links'] = []
for link_json in channel_metadata.get('primaryLinks', ()):
url = link_json['navigationEndpoint']['urlEndpoint']['url']
if url.startswith('/redirect'): # youtube puts these on external links to do tracking
query_string = url[url.find('?')+1: ]
url = urllib.parse.parse_qs(query_string)['q'][0]
text = yt_data_extract.get_plain_text(link_json['title'])
info['links'].append( (text, url) )
info['stats'] = []
for stat_name in ('subscriberCountText', 'joinedDateText', 'viewCountText', 'country'):
try:
stat = channel_metadata[stat_name]
except KeyError:
continue
info['stats'].append(yt_data_extract.get_plain_text(stat))
if 'description' in channel_metadata:
info['description'] = yt_data_extract.get_text(channel_metadata['description'])
else:
info['description'] = ''
else:
action_name = 'Subscribe'
action = 'subscribe'
raise NotImplementedError('Unknown or unsupported channel tab: ' + tab)
try:
description = yt_data_extract.format_text_runs(yt_data_extract.get_formatted_text(channel_metadata['description']))
except KeyError:
description = ''
return yt_channel_about_template.substitute(
header = html_common.get_header(),
page_title = yt_data_extract.get_plain_text(channel_metadata['title']) + ' - About',
channel_title = yt_data_extract.get_plain_text(channel_metadata['title']),
avatar = html.escape(avatar),
description = description,
links = channel_links,
stats = stats,
channel_id = channel_id,
channel_tabs = channel_tabs_html(channel_metadata['channelId'], 'About'),
action_name = action_name,
action = action,
)
return info
def channel_search_page(polymer_json, query, current_page=1, number_of_videos = 1000, current_query_string=''):
response = polymer_json[1]['response']
microformat = get_microformat(response)
channel_url = microformat['urlCanonical'].rstrip('/')
channel_id = channel_url[channel_url.rfind('/')+1:]
if subscriptions.is_subscribed(channel_id):
action_name = 'Unsubscribe'
action = 'unsubscribe'
else:
action_name = 'Subscribe'
action = 'subscribe'
def post_process_channel_info(info):
info['avatar'] = '/' + info['avatar']
info['channel_url'] = '/' + info['channel_url']
for item in info['items']:
yt_data_extract.prefix_urls(item)
yt_data_extract.add_extra_html_info(item)
try:
items = tab_with_content(response['contents']['twoColumnBrowseResultsRenderer']['tabs'])['sectionListRenderer']['contents']
except KeyError:
items = response['continuationContents']['sectionListContinuation']['contents']
items_html = list_items_html(items)
return yt_channel_items_template.substitute(
header = html_common.get_header(),
channel_title = html.escape(microformat['title']),
channel_id = channel_id,
channel_tabs = channel_tabs_html(channel_id, '', query),
avatar = '/' + microformat['thumbnail']['thumbnails'][0]['url'],
page_title = html.escape(query + ' - Channel search'),
items = items_html,
page_buttons = html_common.page_buttons_html(current_page, math.ceil(number_of_videos/29), util.URL_ORIGIN + "/channel/" + channel_id + "/search", current_query_string),
number_of_results = '',
sort_buttons = '',
action_name = action_name,
action = action,
)
def get_channel_search_json(channel_id, query, page):
params = proto.string(2, 'search') + proto.string(15, str(page))
params = proto.percent_b64encode(params)
ctoken = proto.string(2, channel_id) + proto.string(3, params) + proto.string(11, query)
ctoken = base64.urlsafe_b64encode(proto.nested(80226972, ctoken)).decode('ascii')
polymer_json = util.fetch_url("https://www.youtube.com/browse_ajax?ctoken=" + ctoken, util.desktop_ua + headers_1)
'''with open('debug/channel_search_debug', 'wb') as f:
f.write(polymer_json)'''
polymer_json = json.loads(polymer_json)
return polymer_json
playlist_sort_codes = {'2': "da", '3': "dd", '4': "lad"}
def get_channel_page(env, start_response):
path_parts = env['path_parts']
channel_id = path_parts[1]
try:
tab = path_parts[2]
except IndexError:
tab = 'videos'
parameters = env['parameters']
page_number = int(util.default_multi_get(parameters, 'page', 0, default='1'))
sort = util.default_multi_get(parameters, 'sort', 0, default='3')
view = util.default_multi_get(parameters, 'view', 0, default='1')
query = util.default_multi_get(parameters, 'query', 0, default='')
@yt_app.route('/channel/<channel_id>/')
@yt_app.route('/channel/<channel_id>/<tab>')
def get_channel_page(channel_id, tab='videos'):
page_number = int(request.args.get('page', 1))
sort = request.args.get('sort', '3')
view = request.args.get('view', '1')
query = request.args.get('query', '')
if tab == 'videos':
tasks = (
@@ -439,17 +286,10 @@ def get_channel_page(env, start_response):
gevent.joinall(tasks)
number_of_videos, polymer_json = tasks[0].value, tasks[1].value
result = channel_videos_html(polymer_json, page_number, sort, number_of_videos, env['QUERY_STRING'])
elif tab == 'about':
polymer_json = util.fetch_url('https://www.youtube.com/channel/' + channel_id + '/about?pbj=1', util.desktop_ua + headers_1)
polymer_json = json.loads(polymer_json)
result = channel_about_page(polymer_json)
polymer_json = util.fetch_url('https://www.youtube.com/channel/' + channel_id + '/about?pbj=1', util.desktop_ua + headers_1, debug_name='channel_about')
elif tab == 'playlists':
polymer_json = util.fetch_url('https://www.youtube.com/channel/' + channel_id + '/playlists?pbj=1&view=1&sort=' + playlist_sort_codes[sort], util.desktop_ua + headers_1)
'''with open('debug/channel_playlists_debug', 'wb') as f:
f.write(polymer_json)'''
polymer_json = json.loads(polymer_json)
result = channel_playlists_html(polymer_json, sort)
polymer_json = util.fetch_url('https://www.youtube.com/channel/' + channel_id + '/playlists?pbj=1&view=1&sort=' + playlist_sort_codes[sort], util.desktop_ua + headers_1, debug_name='channel_playlists')
elif tab == 'search':
tasks = (
gevent.spawn(get_number_of_videos, channel_id ),
@@ -458,54 +298,80 @@ def get_channel_page(env, start_response):
gevent.joinall(tasks)
number_of_videos, polymer_json = tasks[0].value, tasks[1].value
result = channel_search_page(polymer_json, query, page_number, number_of_videos, env['QUERY_STRING'])
else:
start_response('404 Not Found', [('Content-type', 'text/plain'),])
return b'Unknown channel tab: ' + tab.encode('utf-8')
flask.abort(404, 'Unknown channel tab: ' + tab)
start_response('200 OK', [('Content-type','text/html'),])
return result.encode('utf-8')
# youtube.com/user/[username]/[page]
# youtube.com/c/[custom]/[page]
# youtube.com/[custom]/[page]
def get_channel_page_general_url(env, start_response):
path_parts = env['path_parts']
info = extract_info(json.loads(polymer_json), tab)
post_process_channel_info(info)
if tab in ('videos', 'search'):
info['number_of_videos'] = number_of_videos
info['number_of_pages'] = math.ceil(number_of_videos/30)
info['header_playlist_names'] = local_playlist.get_playlist_names()
if tab in ('videos', 'playlists'):
info['current_sort'] = sort
elif tab == 'search':
info['search_box_value'] = query
is_toplevel = not path_parts[0] in ('user', 'c')
if len(path_parts) + int(is_toplevel) == 3: # has /[page] after it
page = path_parts[2]
base_url = 'https://www.youtube.com/' + '/'.join(path_parts[0:-1])
elif len(path_parts) + int(is_toplevel) == 2: # does not have /[page] after it, use /videos by default
page = 'videos'
base_url = 'https://www.youtube.com/' + '/'.join(path_parts)
else:
start_response('404 Not Found', [('Content-type', 'text/plain'),])
return b'Invalid channel url'
return flask.render_template('channel.html',
parameters_dictionary = request.args,
**info
)
if page == 'videos':
polymer_json = util.fetch_url(base_url + '/videos?pbj=1&view=0', util.desktop_ua + headers_1)
'''with open('debug/user_page_videos', 'wb') as f:
f.write(polymer_json)'''
polymer_json = json.loads(polymer_json)
result = channel_videos_html(polymer_json)
elif page == 'about':
polymer_json = util.fetch_url(base_url + '/about?pbj=1', util.desktop_ua + headers_1)
polymer_json = json.loads(polymer_json)
result = channel_about_page(polymer_json)
elif page == 'playlists':
polymer_json = util.fetch_url(base_url+ '/playlists?pbj=1&view=1', util.desktop_ua + headers_1)
polymer_json = json.loads(polymer_json)
result = channel_playlists_html(polymer_json)
elif page == 'search':
# youtube.com/user/[username]/[tab]
# youtube.com/c/[custom]/[tab]
# youtube.com/[custom]/[tab]
def get_channel_page_general_url(base_url, tab, request):
page_number = int(request.args.get('page', 1))
sort = request.args.get('sort', '3')
view = request.args.get('view', '1')
query = request.args.get('query', '')
if tab == 'videos':
polymer_json = util.fetch_url(base_url + '/videos?pbj=1&view=0', util.desktop_ua + headers_1, debug_name='gen_channel_videos')
elif tab == 'about':
polymer_json = util.fetch_url(base_url + '/about?pbj=1', util.desktop_ua + headers_1, debug_name='gen_channel_about')
elif tab == 'playlists':
polymer_json = util.fetch_url(base_url+ '/playlists?pbj=1&view=1', util.desktop_ua + headers_1, debug_name='gen_channel_playlists')
elif tab == 'search':
raise NotImplementedError()
'''polymer_json = util.fetch_url('https://www.youtube.com/user' + username + '/search?pbj=1&' + query_string, util.desktop_ua + headers_1)
polymer_json = json.loads(polymer_json)
return channel_search_page('''
else:
start_response('404 Not Found', [('Content-type', 'text/plain'),])
return b'Unknown channel page: ' + page.encode('utf-8')
flask.abort(404, 'Unknown channel tab: ' + tab)
info = extract_info(json.loads(polymer_json), tab)
post_process_channel_info(info)
if tab in ('videos', 'search'):
info['number_of_videos'] = 1000
info['number_of_pages'] = math.ceil(1000/30)
info['header_playlist_names'] = local_playlist.get_playlist_names()
if tab in ('videos', 'playlists'):
info['current_sort'] = sort
elif tab == 'search':
info['search_box_value'] = query
return flask.render_template('channel.html',
parameters_dictionary = request.args,
**info
)
@yt_app.route('/user/<username>/')
@yt_app.route('/user/<username>/<tab>')
def get_user_page(username, tab='videos'):
return get_channel_page_general_url('https://www.youtube.com/user/' + username, tab, request)
@yt_app.route('/c/<custom>/')
@yt_app.route('/c/<custom>/<tab>')
def get_custom_c_page(custom, tab='videos'):
return get_channel_page_general_url('https://www.youtube.com/c/' + custom, tab, request)
@yt_app.route('/<custom>')
@yt_app.route('/<custom>/<tab>')
def get_toplevel_custom_page(custom, tab='videos'):
return get_channel_page_general_url('https://www.youtube.com/' + custom, tab, request)
start_response('200 OK', [('Content-type','text/html'),])
return result.encode('utf-8')

View File

@@ -1,57 +1,14 @@
from youtube import proto, util, html_common, yt_data_extract, accounts
from youtube import proto, util, yt_data_extract, accounts
from youtube import yt_app
import settings
import json
import base64
from string import Template
import urllib.request
import urllib
import html
import re
comment_area_template = Template('''
<section class="comment-area">
$video-metadata
$comment-links
$comment-box
$comments
$more-comments-button
</section>
''')
comment_template = Template('''
<div class="comment-container">
<div class="comment">
<a class="author-avatar" href="$author_url" title="$author">
$avatar
</a>
<address>
<a class="author" href="$author_url" title="$author">$author</a>
</address>
<a class="permalink" href="$permalink" title="permalink">
<time datetime="$datetime">$published</time>
</a>
<span class="text">$text</span>
<span class="likes">$likes</span>
<div class="bottom-row">
$replies
$action_buttons
</div>
</div>
</div>
''')
comment_avatar_template = Template(''' <img class="author-avatar-img" src="$author_avatar">''')
reply_link_template = Template('''
<a href="$url" class="replies">$view_replies_text</a>
''')
with open("yt_comments_template.html", "r") as file:
yt_comments_template = Template(file.read())
# <a class="replies-link" href="$replies_url">$replies_link_text</a>
import flask
from flask import request
# Here's what I know about the secret key (starting with ASJN_i)
# *The secret key definitely contains the following information (or perhaps the information is stored at youtube's servers):
@@ -102,6 +59,7 @@ def ctoken_metadata(ctoken):
result['is_replies'] = False
if (3 in offset_information) and (2 in proto.parse(offset_information[3])):
result['is_replies'] = True
result['sort'] = None
else:
try:
result['sort'] = proto.parse(offset_information[4])[6]
@@ -109,12 +67,6 @@ def ctoken_metadata(ctoken):
result['sort'] = 0
return result
def get_ids(ctoken):
params = proto.parse(proto.b64_to_bytes(ctoken))
video_id = proto.parse(params[2])[2]
params = proto.parse(params[6])
params = proto.parse(params[3])
return params[2].decode('ascii'), video_id.decode('ascii')
mobile_headers = {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
@@ -131,7 +83,7 @@ def request_comments(ctoken, replies=False):
url = base_url + ctoken.replace("=", "%3D") + "&pbj=1"
for i in range(0,8): # don't retry more than 8 times
content = util.fetch_url(url, headers=mobile_headers, report_text="Retrieved comments")
content = util.fetch_url(url, headers=mobile_headers, report_text="Retrieved comments", debug_name='request_comments')
if content[0:4] == b")]}'": # random closing characters included at beginning of response for some reason
content = content[4:]
elif content[0:10] == b'\n<!DOCTYPE': # occasionally returns html instead of json for no reason
@@ -139,116 +91,67 @@ def request_comments(ctoken, replies=False):
print("got <!DOCTYPE>, retrying")
continue
break
'''with open('debug/comments_debug', 'wb') as f:
f.write(content)'''
return content
def single_comment_ctoken(video_id, comment_id):
page_params = proto.string(2, video_id) + proto.string(6, proto.percent_b64encode(proto.string(15, comment_id)))
result = proto.nested(2, page_params) + proto.uint(3,6)
return base64.urlsafe_b64encode(result).decode('ascii')
def parse_comments_ajax(content, replies=False):
try:
content = json.loads(util.uppercase_escape(content.decode('utf-8')))
#print(content)
comments_raw = content['content']['continuation_contents']['contents']
ctoken = util.default_multi_get(content, 'content', 'continuation_contents', 'continuations', 0, 'continuation', default='')
comments = []
for comment_raw in comments_raw:
replies_url = ''
if not replies:
if comment_raw['replies'] is not None:
reply_ctoken = comment_raw['replies']['continuations'][0]['continuation']
comment_id, video_id = get_ids(reply_ctoken)
replies_url = util.URL_ORIGIN + '/comments?parent_id=' + comment_id + "&video_id=" + video_id
comment_raw = comment_raw['comment']
comment = {
'author': comment_raw['author']['runs'][0]['text'],
'author_url': comment_raw['author_endpoint']['url'],
'author_channel_id': '',
'author_id': '',
'author_avatar': comment_raw['author_thumbnail']['url'],
'likes': comment_raw['like_count'],
'published': comment_raw['published_time']['runs'][0]['text'],
'text': comment_raw['content']['runs'],
'reply_count': '',
'replies_url': replies_url,
}
comments.append(comment)
except Exception as e:
print('Error parsing comments: ' + str(e))
comments = ()
ctoken = ''
return {'ctoken': ctoken, 'comments': comments}
reply_count_regex = re.compile(r'(\d+)')
def parse_comments_polymer(content, replies=False):
def parse_comments_polymer(content):
try:
video_title = ''
content = json.loads(util.uppercase_escape(content.decode('utf-8')))
url = content[1]['url']
ctoken = urllib.parse.parse_qs(url[url.find('?')+1:])['ctoken'][0]
video_id = ctoken_metadata(ctoken)['video_id']
#print(content)
metadata = ctoken_metadata(ctoken)
try:
comments_raw = content[1]['response']['continuationContents']['commentSectionContinuation']['items']
except KeyError:
comments_raw = content[1]['response']['continuationContents']['commentRepliesContinuation']['contents']
replies = True
ctoken = util.default_multi_get(content, 1, 'response', 'continuationContents', 'commentSectionContinuation', 'continuations', 0, 'nextContinuationData', 'continuation', default='')
comments = []
for comment_raw in comments_raw:
replies_url = ''
view_replies_text = ''
try:
comment_raw = comment_raw['commentThreadRenderer']
except KeyError:
pass
else:
if 'commentTargetTitle' in comment_raw:
video_title = comment_raw['commentTargetTitle']['runs'][0]['text']
parent_id = comment_raw['comment']['commentRenderer']['commentId']
# TODO: move this stuff into the comments_html function
if 'replies' in comment_raw:
#reply_ctoken = comment_raw['replies']['commentRepliesRenderer']['continuations'][0]['nextContinuationData']['continuation']
#comment_id, video_id = get_ids(reply_ctoken)
replies_url = util.URL_ORIGIN + '/comments?parent_id=' + parent_id + "&video_id=" + video_id
view_replies_text = yt_data_extract.get_plain_text(comment_raw['replies']['commentRepliesRenderer']['moreText'])
match = reply_count_regex.search(view_replies_text)
comments = []
for comment_json in comments_raw:
number_of_replies = 0
try:
comment_thread = comment_json['commentThreadRenderer']
except KeyError:
comment_renderer = comment_json['commentRenderer']
else:
if 'commentTargetTitle' in comment_thread:
video_title = comment_thread['commentTargetTitle']['runs'][0]['text']
if 'replies' in comment_thread:
view_replies_text = yt_data_extract.get_plain_text(comment_thread['replies']['commentRepliesRenderer']['moreText'])
view_replies_text = view_replies_text.replace(',', '')
match = re.search(r'(\d+)', view_replies_text)
if match is None:
view_replies_text = '1 reply'
number_of_replies = 1
else:
view_replies_text = match.group(1) + " replies"
elif not replies:
view_replies_text = "Reply"
replies_url = util.URL_ORIGIN + '/post_comment?parent_id=' + parent_id + "&video_id=" + video_id
comment_raw = comment_raw['comment']
comment_raw = comment_raw['commentRenderer']
number_of_replies = int(match.group(1))
comment_renderer = comment_thread['comment']['commentRenderer']
comment = {
'author_id': comment_raw.get('authorId', ''),
'author_avatar': comment_raw['authorThumbnail']['thumbnails'][0]['url'],
'likes': comment_raw['likeCount'],
'published': yt_data_extract.get_plain_text(comment_raw['publishedTimeText']),
'text': comment_raw['contentText'].get('runs', ''),
'view_replies_text': view_replies_text,
'replies_url': replies_url,
'video_id': video_id,
'comment_id': comment_raw['commentId'],
'author_id': comment_renderer.get('authorId', ''),
'author_avatar': comment_renderer['authorThumbnail']['thumbnails'][0]['url'],
'likes': comment_renderer['likeCount'],
'published': yt_data_extract.get_plain_text(comment_renderer['publishedTimeText']),
'text': comment_renderer['contentText'].get('runs', ''),
'number_of_replies': number_of_replies,
'comment_id': comment_renderer['commentId'],
}
if 'authorText' in comment_raw: # deleted channels have no name or channel link
comment['author'] = yt_data_extract.get_plain_text(comment_raw['authorText'])
comment['author_url'] = comment_raw['authorEndpoint']['commandMetadata']['webCommandMetadata']['url']
comment['author_channel_id'] = comment_raw['authorEndpoint']['browseEndpoint']['browseId']
if 'authorText' in comment_renderer: # deleted channels have no name or channel link
comment['author'] = yt_data_extract.get_plain_text(comment_renderer['authorText'])
comment['author_url'] = comment_renderer['authorEndpoint']['commandMetadata']['webCommandMetadata']['url']
comment['author_channel_id'] = comment_renderer['authorEndpoint']['browseEndpoint']['browseId']
else:
comment['author'] = ''
comment['author_url'] = ''
@@ -260,172 +163,109 @@ def parse_comments_polymer(content, replies=False):
comments = ()
ctoken = ''
return {'ctoken': ctoken, 'comments': comments, 'video_title': video_title}
return {
'ctoken': ctoken,
'comments': comments,
'video_title': video_title,
'video_id': metadata['video_id'],
'offset': metadata['offset'],
'is_replies': metadata['is_replies'],
'sort': metadata['sort'],
}
def post_process_comments_info(comments_info):
for comment in comments_info['comments']:
comment['author_url'] = util.URL_ORIGIN + comment['author_url']
comment['author_avatar'] = '/' + comment['author_avatar']
comment['permalink'] = util.URL_ORIGIN + '/watch?v=' + comments_info['video_id'] + '&lc=' + comment['comment_id']
def get_comments_html(comments):
html_result = ''
for comment in comments:
replies = ''
if comment['replies_url']:
replies = reply_link_template.substitute(url=comment['replies_url'], view_replies_text=html.escape(comment['view_replies_text']))
if settings.enable_comment_avatars:
avatar = comment_avatar_template.substitute(
author_url = util.URL_ORIGIN + comment['author_url'],
author_avatar = '/' + comment['author_avatar'],
)
else:
avatar = ''
if comment['author_channel_id'] in accounts.accounts:
delete_url = (util.URL_ORIGIN + '/delete_comment?video_id='
+ comment['video_id']
comment['delete_url'] = (util.URL_ORIGIN + '/delete_comment?video_id='
+ comments_info['video_id']
+ '&channel_id='+ comment['author_channel_id']
+ '&author_id=' + comment['author_id']
+ '&comment_id=' + comment['comment_id'])
action_buttons = '''<a href="''' + delete_url + '''" target="_blank">Delete</a>'''
num_replies = comment['number_of_replies']
if num_replies == 0:
comment['replies_url'] = util.URL_ORIGIN + '/post_comment?parent_id=' + comment['comment_id'] + "&video_id=" + comments_info['video_id']
else:
action_buttons = ''
comment['replies_url'] = util.URL_ORIGIN + '/comments?parent_id=' + comment['comment_id'] + "&video_id=" + comments_info['video_id']
if num_replies == 0:
comment['view_replies_text'] = 'Reply'
elif num_replies == 1:
comment['view_replies_text'] = '1 reply'
else:
comment['view_replies_text'] = str(num_replies) + ' replies'
if comment['likes'] == 1:
comment['likes_text'] = '1 like'
else:
comment['likes_text'] = str(comment['likes']) + ' likes'
comments_info['include_avatars'] = settings.enable_comment_avatars
if comments_info['ctoken'] != '':
comments_info['more_comments_url'] = util.URL_ORIGIN + '/comments?ctoken=' + comments_info['ctoken']
comments_info['page_number'] = page_number = str(int(comments_info['offset']/20) + 1)
if not comments_info['is_replies']:
comments_info['sort_text'] = 'top' if comments_info['sort'] == 0 else 'newest'
comments_info['video_url'] = util.URL_ORIGIN + '/watch?v=' + comments_info['video_id']
comments_info['video_thumbnail'] = '/i.ytimg.com/vi/'+ comments_info['video_id'] + '/mqdefault.jpg'
permalink = util.URL_ORIGIN + '/watch?v=' + comment['video_id'] + '&lc=' + comment['comment_id']
html_result += comment_template.substitute(
author=comment['author'],
author_url = util.URL_ORIGIN + comment['author_url'],
avatar = avatar,
likes = str(comment['likes']) + ' likes' if str(comment['likes']) != '0' else '',
published = comment['published'],
text = yt_data_extract.format_text_runs(comment['text']),
datetime = '', #TODO
replies = replies,
action_buttons = action_buttons,
permalink = permalink,
)
return html_result
def video_comments(video_id, sort=0, offset=0, lc='', secret_key=''):
if settings.enable_comments:
if settings.comments_mode:
comments_info = parse_comments_polymer(request_comments(make_comment_ctoken(video_id, sort, offset, lc, secret_key)))
post_process_comments_info(comments_info)
post_comment_url = util.URL_ORIGIN + "/post_comment?video_id=" + video_id
post_comment_link = '''<a class="sort-button" href="''' + post_comment_url + '''">Post comment</a>'''
other_sort_url = util.URL_ORIGIN + '/comments?ctoken=' + make_comment_ctoken(video_id, sort=1 - sort, lc=lc)
other_sort_name = 'newest' if sort == 0 else 'top'
other_sort_link = '''<a class="sort-button" href="''' + other_sort_url + '''">Sort by ''' + other_sort_name + '''</a>'''
other_sort_text = 'Sort by ' + ('newest' if sort == 0 else 'top')
comments_info['comment_links'] = [('Post comment', post_comment_url), (other_sort_text, other_sort_url)]
comment_links = '''<div class="comment-links">\n'''
comment_links += other_sort_link + '\n' + post_comment_link + '\n'
comment_links += '''</div>'''
comment_info = parse_comments_polymer(request_comments(make_comment_ctoken(video_id, sort, offset, lc, secret_key)))
ctoken = comment_info['ctoken']
return comments_info
if ctoken == '':
more_comments_button = ''
else:
more_comments_button = more_comments_template.substitute(url = util.URL_ORIGIN + '/comments?ctoken=' + ctoken)
return {}
result = '''<section class="comments-area">\n'''
result += comment_links + '\n'
result += '<div class="comments">\n'
result += get_comments_html(comment_info['comments']) + '\n'
result += '</div>\n'
result += more_comments_button + '\n'
result += '''</section>'''
return result
return ''
more_comments_template = Template('''<a class="page-button more-comments" href="$url">More comments</a>''')
video_metadata_template = Template('''<section class="video-metadata">
<a class="video-metadata-thumbnail-box" href="$url" title="$title">
<img class="video-metadata-thumbnail-img" src="$thumbnail" height="180px" width="320px">
</a>
<a class="title" href="$url" title="$title">$title</a>
<h2>Comments page $page_number</h2>
<span>Sorted by $sort</span>
</section>
''')
account_option_template = Template('''
<option value="$channel_id">$display_name</option>''')
def comment_box_account_options():
return ''.join(account_option_template.substitute(channel_id=channel_id, display_name=display_name) for channel_id, display_name in accounts.account_list_data())
comment_box_template = Template('''
<form action="$form_action" method="post" class="comment-form">
<div id="comment-account-options">
<label for="account-selection">Account:</label>
<select id="account-selection" name="channel_id">
$options
</select>
<a href="''' + util.URL_ORIGIN + '''/login" target="_blank">Add account</a>
</div>
<textarea name="comment_text"></textarea>
$video_id_input
<button type="submit" class="post-comment-button">$post_text</button>
</form>''')
def get_comments_page(env, start_response):
start_response('200 OK', [('Content-type','text/html'),] )
parameters = env['parameters']
ctoken = util.default_multi_get(parameters, 'ctoken', 0, default='')
@yt_app.route('/comments')
def get_comments_page():
ctoken = request.args.get('ctoken', '')
replies = False
if not ctoken:
video_id = parameters['video_id'][0]
parent_id = parameters['parent_id'][0]
video_id = request.args['video_id']
parent_id = request.args['parent_id']
ctoken = comment_replies_ctoken(video_id, parent_id)
replies = True
comment_info = parse_comments_polymer(request_comments(ctoken, replies), replies)
comments_info = parse_comments_polymer(request_comments(ctoken, replies))
post_process_comments_info(comments_info)
metadata = ctoken_metadata(ctoken)
if replies:
page_title = 'Replies'
video_metadata = ''
comment_box = comment_box_template.substitute(form_action='', video_id_input='', post_text='Post reply', options=comment_box_account_options())
comment_links = ''
else:
page_number = str(int(metadata['offset']/20) + 1)
page_title = 'Comments page ' + page_number
video_metadata = video_metadata_template.substitute(
page_number = page_number,
sort = 'top' if metadata['sort'] == 0 else 'newest',
title = html.escape(comment_info['video_title']),
url = util.URL_ORIGIN + '/watch?v=' + metadata['video_id'],
thumbnail = '/i.ytimg.com/vi/'+ metadata['video_id'] + '/mqdefault.jpg',
)
comment_box = comment_box_template.substitute(
form_action= util.URL_ORIGIN + '/post_comment',
video_id_input='''<input type="hidden" name="video_id" value="''' + metadata['video_id'] + '''">''',
post_text='Post comment',
options=comment_box_account_options(),
)
other_sort_url = util.URL_ORIGIN + '/comments?ctoken=' + make_comment_ctoken(metadata['video_id'], sort=1 - metadata['sort'])
other_sort_name = 'newest' if metadata['sort'] == 0 else 'top'
other_sort_link = '''<a class="sort-button" href="''' + other_sort_url + '''">Sort by ''' + other_sort_name + '''</a>'''
if not replies:
other_sort_url = util.URL_ORIGIN + '/comments?ctoken=' + make_comment_ctoken(comments_info['video_id'], sort=1 - comments_info['sort'])
other_sort_text = 'Sort by ' + ('newest' if comments_info['sort'] == 0 else 'top')
comments_info['comment_links'] = [(other_sort_text, other_sort_url)]
comment_links = '''<div class="comment-links">\n'''
comment_links += other_sort_link + '\n'
comment_links += '''</div>'''
comment_posting_box_info = {
'form_action': '' if replies else util.URL_ORIGIN + '/post_comment',
'video_id': comments_info['video_id'],
'accounts': accounts.account_list_data(),
'include_video_id_input': not replies,
'replying': replies,
}
return flask.render_template('comments_page.html',
comments_info = comments_info,
comment_posting_box_info = comment_posting_box_info,
)
comments_html = get_comments_html(comment_info['comments'])
ctoken = comment_info['ctoken']
if ctoken == '':
more_comments_button = ''
else:
more_comments_button = more_comments_template.substitute(url = util.URL_ORIGIN + '/comments?ctoken=' + ctoken)
comments_area = '<section class="comments-area">\n'
comments_area += video_metadata + comment_box + comment_links + '\n'
comments_area += '<div class="comments">\n'
comments_area += comments_html + '\n'
comments_area += '</div>\n'
comments_area += more_comments_button + '\n'
comments_area += '</section>\n'
return yt_comments_template.substitute(
header = html_common.get_header(),
comments_area = comments_area,
page_title = page_title,
).encode('utf-8')

View File

@@ -1,379 +0,0 @@
from youtube.template import Template
from youtube import local_playlist, yt_data_extract, util
import json
import html
with open('yt_basic_template.html', 'r', encoding='utf-8') as file:
yt_basic_template = Template(file.read())
page_button_template = Template('''<a class="page-button" href="$href">$page</a>''')
current_page_button_template = Template('''<div class="current-page-button">$page</a>''')
medium_playlist_item_template = Template('''
<div class="medium-item-box">
<div class="medium-item">
<a class="playlist-thumbnail-box" href="$url" title="$title">
<img class="playlist-thumbnail-img" src="$thumbnail">
<div class="playlist-thumbnail-info">
<span>$size</span>
</div>
</a>
<a class="title" href="$url" title="$title">$title</a>
<div class="stats">$stats</div>
</div>
</div>
''')
medium_video_item_template = Template('''
<div class="medium-item-box">
<div class="medium-item">
<a class="video-thumbnail-box" href="$url" title="$title">
<img class="video-thumbnail-img" src="$thumbnail">
<span class="video-duration">$duration</span>
</a>
<a class="title" href="$url" title="$title">$title</a>
<div class="stats">$stats</div>
<span class="description">$description</span>
<span class="badges">$badges</span>
</div>
<input class="item-checkbox" type="checkbox" name="video_info_list" value="$video_info" form="playlist-edit">
</div>
''')
small_video_item_template = Template('''
<div class="small-item-box">
<div class="small-item">
<a class="video-thumbnail-box" href="$url" title="$title">
<img class="video-thumbnail-img" src="$thumbnail">
<span class="video-duration">$duration</span>
</a>
<a class="title" href="$url" title="$title">$title</a>
<address>$author</address>
<span class="views">$views</span>
</div>
<input class="item-checkbox" type="checkbox" name="video_info_list" value="$video_info" form="playlist-edit">
</div>
''')
small_playlist_item_template = Template('''
<div class="small-item-box">
<div class="small-item">
<a class="playlist-thumbnail-box" href="$url" title="$title">
<img class="playlist-thumbnail-img" src="$thumbnail">
<div class="playlist-thumbnail-info">
<span>$size</span>
</div>
</a>
<a class="title" href="$url" title="$title">$title</a>
<address>$author</address>
</div>
</div>
''')
medium_channel_item_template = Template('''
<div class="medium-item-box">
<div class="medium-item">
<a class="video-thumbnail-box" href="$url" title="$title">
<img class="video-thumbnail-img" src="$thumbnail">
<span class="video-duration">$duration</span>
</a>
<a class="title" href="$url">$title</a>
<span>$subscriber_count</span>
<span>$size</span>
<span class="description">$description</span>
</div>
</div>
''')
header_template = Template('''
<header>
<form id="site-search" action="/youtube.com/search">
<input type="search" name="query" class="search-box" value="$search_box_value">
<button type="submit" value="Search" class="search-button">Search</button>
<div class="dropdown">
<button class="dropdown-label">Options</button>
<div class="css-sucks">
<div class="dropdown-content">
<h3>Sort by</h3>
<input type="radio" id="sort_relevance" name="sort" value="0">
<label for="sort_relevance">Relevance</label>
<input type="radio" id="sort_upload_date" name="sort" value="2">
<label for="sort_upload_date">Upload date</label>
<input type="radio" id="sort_view_count" name="sort" value="3">
<label for="sort_view_count">View count</label>
<input type="radio" id="sort_rating" name="sort" value="1">
<label for="sort_rating">Rating</label>
<h3>Upload date</h3>
<input type="radio" id="time_any" name="time" value="0">
<label for="time_any">Any</label>
<input type="radio" id="time_last_hour" name="time" value="1">
<label for="time_last_hour">Last hour</label>
<input type="radio" id="time_today" name="time" value="2">
<label for="time_today">Today</label>
<input type="radio" id="time_this_week" name="time" value="3">
<label for="time_this_week">This week</label>
<input type="radio" id="time_this_month" name="time" value="4">
<label for="time_this_month">This month</label>
<input type="radio" id="time_this_year" name="time" value="5">
<label for="time_this_year">This year</label>
<h3>Type</h3>
<input type="radio" id="type_any" name="type" value="0">
<label for="type_any">Any</label>
<input type="radio" id="type_video" name="type" value="1">
<label for="type_video">Video</label>
<input type="radio" id="type_channel" name="type" value="2">
<label for="type_channel">Channel</label>
<input type="radio" id="type_playlist" name="type" value="3">
<label for="type_playlist">Playlist</label>
<input type="radio" id="type_movie" name="type" value="4">
<label for="type_movie">Movie</label>
<input type="radio" id="type_show" name="type" value="5">
<label for="type_show">Show</label>
<h3>Duration</h3>
<input type="radio" id="duration_any" name="duration" value="0">
<label for="duration_any">Any</label>
<input type="radio" id="duration_short" name="duration" value="1">
<label for="duration_short">Short (< 4 minutes)</label>
<input type="radio" id="duration_long" name="duration" value="2">
<label for="duration_long">Long (> 20 minutes)</label>
</div>
</div>
</div>
</form>
<div id="header-right">
<form id="playlist-edit" action="/youtube.com/edit_playlist" method="post" target="_self">
<input name="playlist_name" id="playlist-name-selection" list="playlist-options" type="text">
<datalist id="playlist-options">
$playlists
</datalist>
<button type="submit" id="playlist-add-button" name="action" value="add">Add to playlist</button>
<button type="reset" id="item-selection-reset">Clear selection</button>
</form>
<a href="/youtube.com/playlists" id="local-playlists">Local playlists</a>
</div>
</header>
''')
playlist_option_template = Template('''<option value="$name">$name</option>''')
def get_header(search_box_value=""):
playlists = ''
for name in local_playlist.get_playlist_names():
playlists += playlist_option_template.substitute(name = name)
return header_template.substitute(playlists = playlists, search_box_value = html.escape(search_box_value))
def badges_html(badges):
return ' | '.join(map(html.escape, badges))
html_transform_dispatch = {
'title': html.escape,
'published': html.escape,
'id': html.escape,
'description': yt_data_extract.format_text_runs,
'duration': html.escape,
'thumbnail': lambda url: html.escape('/' + url.lstrip('/')),
'size': html.escape,
'author': html.escape,
'author_url': lambda url: html.escape(util.URL_ORIGIN + url),
'views': html.escape,
'subscriber_count': html.escape,
'badges': badges_html,
'playlist_index': html.escape,
}
def get_html_ready(item):
html_ready = {}
for key, value in item.items():
try:
function = html_transform_dispatch[key]
except KeyError:
continue
html_ready[key] = function(value)
return html_ready
author_template_url = Template('''<address>By <a href="$author_url">$author</a></address>''')
author_template = Template('''<address><b>$author</b></address>''')
stat_templates = (
Template('''<span class="views">$views</span>'''),
Template('''<time datetime="$datetime">$published</time>'''),
)
def get_stats(html_ready):
stats = []
if 'author' in html_ready:
if 'author_url' in html_ready:
stats.append(author_template_url.substitute(html_ready))
else:
stats.append(author_template.substitute(html_ready))
for stat in stat_templates:
try:
stats.append(stat.strict_substitute(html_ready))
except KeyError:
pass
return ' | '.join(stats)
def video_item_html(item, template, html_exclude=set()):
video_info = {}
for key in ('id', 'title', 'author'):
try:
video_info[key] = item[key]
except KeyError:
video_info[key] = ''
try:
video_info['duration'] = item['duration']
except KeyError:
video_info['duration'] = 'Live' # livestreams don't have a duration
html_ready = get_html_ready(item)
html_ready['video_info'] = html.escape(json.dumps(video_info) )
html_ready['url'] = util.URL_ORIGIN + "/watch?v=" + html_ready['id']
html_ready['datetime'] = '' #TODO
for key in html_exclude:
del html_ready[key]
html_ready['stats'] = get_stats(html_ready)
return template.substitute(html_ready)
def playlist_item_html(item, template, html_exclude=set()):
html_ready = get_html_ready(item)
html_ready['url'] = util.URL_ORIGIN + "/playlist?list=" + html_ready['id']
html_ready['datetime'] = '' #TODO
for key in html_exclude:
del html_ready[key]
html_ready['stats'] = get_stats(html_ready)
return template.substitute(html_ready)
page_button_template = Template('''<a class="page-button" href="$href">$page</a>''')
current_page_button_template = Template('''<div class="page-button">$page</div>''')
def page_buttons_html(current_page, estimated_pages, url, current_query_string):
if current_page <= 5:
page_start = 1
page_end = min(9, estimated_pages)
else:
page_start = current_page - 4
page_end = min(current_page + 4, estimated_pages)
result = ""
for page in range(page_start, page_end+1):
if page == current_page:
template = current_page_button_template
else:
template = page_button_template
result += template.substitute(page=page, href = url + "?" + util.update_query_string(current_query_string, {'page': [str(page)]}) )
return result
showing_results_for = Template('''
<div class="showing-results-for">
<div>Showing results for <a>$corrected_query</a></div>
<div>Search instead for <a href="$original_query_url">$original_query</a></div>
</div>
''')
did_you_mean = Template('''
<div class="did-you-mean">
<div>Did you mean <a href="$corrected_query_url">$corrected_query</a></div>
</div>
''')
def renderer_html(renderer, additional_info={}, current_query_string=''):
type = list(renderer.keys())[0]
renderer = renderer[type]
if type == 'itemSectionRenderer':
return renderer_html(renderer['contents'][0], additional_info, current_query_string)
if type == 'channelRenderer':
info = yt_data_extract.renderer_info(renderer)
html_ready = get_html_ready(info)
html_ready['url'] = util.URL_ORIGIN + "/channel/" + html_ready['id']
return medium_channel_item_template.substitute(html_ready)
if type in ('movieRenderer', 'clarificationRenderer'):
return ''
info = yt_data_extract.renderer_info(renderer)
info.update(additional_info)
html_exclude = set(additional_info.keys())
if type == 'compactVideoRenderer':
return video_item_html(info, small_video_item_template, html_exclude=html_exclude)
if type in ('compactPlaylistRenderer', 'compactRadioRenderer', 'compactShowRenderer'):
return playlist_item_html(info, small_playlist_item_template, html_exclude=html_exclude)
if type in ('videoRenderer', 'gridVideoRenderer'):
return video_item_html(info, medium_video_item_template, html_exclude=html_exclude)
if type in ('playlistRenderer', 'gridPlaylistRenderer', 'radioRenderer', 'gridRadioRenderer', 'gridShowRenderer', 'showRenderer'):
return playlist_item_html(info, medium_playlist_item_template, html_exclude=html_exclude)
#print(renderer)
#raise NotImplementedError('Unknown renderer type: ' + type)
return ''

View File

@@ -1,5 +1,5 @@
from youtube.template import Template
from youtube import util, html_common
from youtube import util, yt_data_extract
from youtube import yt_app
import settings
import os
@@ -7,13 +7,14 @@ import json
import html
import gevent
import urllib
import math
import flask
from flask import request
playlists_directory = os.path.join(settings.data_dir, "playlists")
thumbnails_directory = os.path.join(settings.data_dir, "playlist_thumbnails")
with open('yt_local_playlist_template.html', 'r', encoding='utf-8') as file:
local_playlist_template = Template(file.read())
def video_ids_in_playlist(name):
try:
with open(os.path.join(playlists_directory, name + ".txt"), 'r', encoding='utf-8') as file:
@@ -36,36 +37,34 @@ def add_to_playlist(name, video_info_list):
gevent.spawn(util.download_thumbnails, os.path.join(thumbnails_directory, name), missing_thumbnails)
def get_local_playlist_page(name):
def get_local_playlist_videos(name, offset=0, amount=50):
try:
thumbnails = set(os.listdir(os.path.join(thumbnails_directory, name)))
except FileNotFoundError:
thumbnails = set()
missing_thumbnails = []
videos_html = ''
videos = []
with open(os.path.join(playlists_directory, name + ".txt"), 'r', encoding='utf-8') as file:
videos = file.read()
videos = videos.splitlines()
for video in videos:
data = file.read()
videos_json = data.splitlines()
for video_json in videos_json:
try:
info = json.loads(video)
info = json.loads(video_json)
if info['id'] + ".jpg" in thumbnails:
info['thumbnail'] = "/youtube.com/data/playlist_thumbnails/" + name + "/" + info['id'] + ".jpg"
else:
info['thumbnail'] = util.get_thumbnail_url(info['id'])
missing_thumbnails.append(info['id'])
videos_html += html_common.video_item_html(info, html_common.small_video_item_template)
info['item_size'] = 'small'
info['type'] = 'video'
yt_data_extract.add_extra_html_info(info)
videos.append(info)
except json.decoder.JSONDecodeError:
pass
if not video_json.strip() == '':
print('Corrupt playlist video entry: ' + video_json)
gevent.spawn(util.download_thumbnails, os.path.join(thumbnails_directory, name), missing_thumbnails)
return local_playlist_template.substitute(
page_title = name + ' - Local playlist',
header = html_common.get_header(),
videos = videos_html,
title = name,
page_buttons = ''
)
return videos[offset:offset+amount], len(videos)
def get_playlist_names():
try:
@@ -98,47 +97,47 @@ def remove_from_playlist(name, video_info_list):
for file in to_delete:
os.remove(os.path.join(thumbnails_directory, name, file))
def get_playlists_list_page():
page = '''<ul>\n'''
list_item_template = Template(''' <li><a href="$url">$name</a></li>\n''')
for name in get_playlist_names():
page += list_item_template.substitute(url = html.escape(util.URL_ORIGIN + '/playlists/' + name), name = html.escape(name))
page += '''</ul>\n'''
return html_common.yt_basic_template.substitute(
page_title = "Local playlists",
header = html_common.get_header(),
style = '',
page = page,
)
return len(videos_out)
def get_playlist_page(env, start_response):
start_response('200 OK', [('Content-type','text/html'),])
path_parts = env['path_parts']
if len(path_parts) == 1:
return get_playlists_list_page().encode('utf-8')
@yt_app.route('/playlists', methods=['GET'])
@yt_app.route('/playlists/<playlist_name>', methods=['GET'])
def get_local_playlist_page(playlist_name=None):
if playlist_name is None:
playlists = [(name, util.URL_ORIGIN + '/playlists/' + name) for name in get_playlist_names()]
return flask.render_template('local_playlists_list.html', playlists=playlists)
else:
return get_local_playlist_page(path_parts[1]).encode('utf-8')
page = int(request.args.get('page', 1))
offset = 50*(page - 1)
videos, num_videos = get_local_playlist_videos(playlist_name, offset=offset, amount=50)
return flask.render_template('local_playlist.html',
playlist_name = playlist_name,
videos = videos,
num_pages = math.ceil(num_videos/50),
parameters_dictionary = request.args,
)
def path_edit_playlist(env, start_response):
@yt_app.route('/playlists/<playlist_name>', methods=['POST'])
def path_edit_playlist(playlist_name):
'''Called when making changes to the playlist from that playlist's page'''
parameters = env['parameters']
if parameters['action'][0] == 'remove':
playlist_name = env['path_parts'][1]
remove_from_playlist(playlist_name, parameters['video_info_list'])
start_response('303 See Other', [('Location', util.URL_ORIGIN + env['PATH_INFO']),] )
return b''
if request.values['action'] == 'remove':
videos_to_remove = request.values.getlist('video_info_list')
number_of_videos_remaining = remove_from_playlist(playlist_name, videos_to_remove)
redirect_page_number = min(int(request.values.get('page', 1)), math.ceil(number_of_videos_remaining/50))
return flask.redirect(util.URL_ORIGIN + request.path + '?page=' + str(redirect_page_number))
else:
start_response('400 Bad Request', [('Content-type', 'text/plain'),])
return b'400 Bad Request'
flask.abort(400)
def edit_playlist(env, start_response):
@yt_app.route('/edit_playlist', methods=['POST'])
def edit_playlist():
'''Called when adding videos to a playlist from elsewhere'''
parameters = env['parameters']
if parameters['action'][0] == 'add':
add_to_playlist(parameters['playlist_name'][0], parameters['video_info_list'])
start_response('204 No Content', ())
if request.values['action'] == 'add':
add_to_playlist(request.values['playlist_name'], request.values.getlist('video_info_list'))
return '', 204
else:
start_response('400 Bad Request', [('Content-type', 'text/plain'),])
return b'400 Bad Request'
flask.abort(400)
@yt_app.route('/data/playlist_thumbnails/<playlist_name>/<thumbnail>')
def serve_thumbnail(playlist_name, thumbnail):
# .. is necessary because flask always uses the application directory at ./youtube, not the working directory
return flask.send_from_directory(os.path.join('..', thumbnails_directory, playlist_name), thumbnail)

View File

@@ -4,7 +4,7 @@
<InputEncoding>UTF-8</InputEncoding>
<Image width="16" height="16">data:image/x-icon;base64,AAABAAEAEBAAAAEACAAlAgAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAexJREFUOI2lkzFPmlEUhp/73fshtCUCRtvQkJoKMrDQJvoHnBzUhc3EH0DUQf+As6tujo4M6mTiIDp0kGiMTRojTRNSW6o12iD4YYXv3g7Qr4O0ScM7npz7vOe+J0fk83lDF7K6eQygwkdHhI+P0bYNxmBXq5RmZui5vGQgn0f7fKi7O4oLC1gPD48BP9JpnpRKJFZXcQMB3m1u4vr9NHp76d/bo39/n4/z84ROThBa4/r91OJxMKb9BSn5mskAIOt1eq6uEFpjVyrEcjk+T0+TXlzkbTZLuFDAur9/nIFRipuREQCe7+zgBgK8mZvj/fIylVTKa/6UzXKbSnnuHkA0GnwbH/cA0a0takND3IyOEiwWAXBiMYTWjzLwtvB9bAyAwMUF8ZUVPiwtYTWbHqA6PIxoNv8OMLbN3eBga9TZWYQxaKX+AJJJhOv+AyAlT0slAG6TSX5n8+zszJugkzxA4PzcK9YSCQCk42DXaq1aGwqgfT5ebG9jpMQyUjKwu8vrtbWWqxC83NjAd31NsO2uleJnX58HCJ6eEjk8BGNQAA+RCOXJScpTU2AMwnUxlkXk4ACA+2iUSKGArNeRjkMsl6M8MYHQGtHpmIxSvFpfRzoORinQGqvZBCEwQoAxfMlkaIRCnQH/o66v8Re19MavaDNLfgAAAABJRU5ErkJggg==</Image>
<Url type="text/html" method="GET" template="http://localhost/youtube.com/search">
<Url type="text/html" method="GET" template="http://localhost:$port_number/youtube.com/search">
<Param name="query" value="{searchTerms}"/>
</Url>
<SearchForm>http://localhost:$port_number/youtube.com/search</SearchForm>

View File

@@ -1,4 +1,5 @@
from youtube import util, yt_data_extract, html_common, template, proto
from youtube import util, yt_data_extract, proto
from youtube import yt_app
import base64
import urllib
@@ -6,10 +7,8 @@ import json
import string
import gevent
import math
with open("yt_playlist_template.html", "r") as file:
yt_playlist_template = template.Template(file.read())
from flask import request
import flask
@@ -48,9 +47,7 @@ headers_1 = (
def playlist_first_page(playlist_id, report_text = "Retrieved playlist"):
url = 'https://m.youtube.com/playlist?list=' + playlist_id + '&pbj=1'
content = util.fetch_url(url, util.mobile_ua + headers_1, report_text=report_text)
'''with open('debug/playlist_debug', 'wb') as f:
f.write(content)'''
content = util.fetch_url(url, util.mobile_ua + headers_1, report_text=report_text, debug_name='playlist_first_page')
content = json.loads(util.uppercase_escape(content.decode('utf-8')))
return content
@@ -68,22 +65,21 @@ def get_videos(playlist_id, page):
'X-YouTube-Client-Version': '2.20180508',
}
content = util.fetch_url(url, headers, report_text="Retrieved playlist")
'''with open('debug/playlist_debug', 'wb') as f:
f.write(content)'''
content = util.fetch_url(url, headers, report_text="Retrieved playlist", debug_name='playlist_videos')
info = json.loads(util.uppercase_escape(content.decode('utf-8')))
return info
playlist_stat_template = string.Template('''
<div>$stat</div>''')
def get_playlist_page(env, start_response):
start_response('200 OK', [('Content-type','text/html'),])
parameters = env['parameters']
playlist_id = parameters['list'][0]
page = parameters.get("page", "1")[0]
if page == "1":
@yt_app.route('/playlist')
def get_playlist_page():
if 'list' not in request.args:
abort(400)
playlist_id = request.args.get('list')
page = request.args.get('page', '1')
if page == '1':
first_page_json = playlist_first_page(playlist_id)
this_page_json = first_page_json
else:
@@ -98,26 +94,20 @@ def get_playlist_page(env, start_response):
video_list = this_page_json['response']['contents']['singleColumnBrowseResultsRenderer']['tabs'][0]['tabRenderer']['content']['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]['playlistVideoListRenderer']['contents']
except KeyError: # other pages
video_list = this_page_json['response']['continuationContents']['playlistVideoListContinuation']['contents']
videos_html = ''
for video_json in video_list:
info = yt_data_extract.renderer_info(video_json['playlistVideoRenderer'])
videos_html += html_common.video_item_html(info, html_common.small_video_item_template)
parsed_video_list = [yt_data_extract.parse_info_prepare_for_html(video_json) for video_json in video_list]
metadata = yt_data_extract.renderer_info(first_page_json['response']['header']['playlistHeaderRenderer'])
metadata = yt_data_extract.renderer_info(first_page_json['response']['header'])
yt_data_extract.prefix_urls(metadata)
video_count = int(metadata['size'].replace(',', ''))
page_buttons = html_common.page_buttons_html(int(page), math.ceil(video_count/20), util.URL_ORIGIN + "/playlist", env['QUERY_STRING'])
metadata['size'] += ' videos'
html_ready = html_common.get_html_ready(metadata)
html_ready['page_title'] = html_ready['title'] + ' - Page ' + str(page)
return flask.render_template('playlist.html',
video_list = parsed_video_list,
num_pages = math.ceil(video_count/20),
parameters_dictionary = request.args,
stats = ''
stats += playlist_stat_template.substitute(stat=html_ready['size'] + ' videos')
stats += playlist_stat_template.substitute(stat=html_ready['views'])
return yt_playlist_template.substitute(
header = html_common.get_header(),
videos = videos_html,
page_buttons = page_buttons,
stats = stats,
**html_ready
**metadata
).encode('utf-8')

View File

@@ -1,5 +1,6 @@
# Contains functions having to do with posting/editing/deleting comments
from youtube import util, html_common, proto, comments, accounts
from youtube import util, proto, comments, accounts
from youtube import yt_app
import settings
import urllib
@@ -8,6 +9,9 @@ import re
import traceback
import os
import flask
from flask import request
def _post_comment(text, video_id, session_token, cookiejar):
headers = {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
@@ -31,13 +35,11 @@ def _post_comment(text, video_id, session_token, cookiejar):
data = urllib.parse.urlencode(data_dict).encode()
content = util.fetch_url("https://m.youtube.com/service_ajax?name=createCommentEndpoint", headers=headers, data=data, cookiejar_send=cookiejar)
content = util.fetch_url("https://m.youtube.com/service_ajax?name=createCommentEndpoint", headers=headers, data=data, cookiejar_send=cookiejar, debug_name='post_comment')
code = json.loads(content)['code']
print("Comment posting code: " + code)
return code
'''with open('debug/post_comment_response', 'wb') as f:
f.write(content)'''
def _post_comment_reply(text, video_id, parent_comment_id, session_token, cookiejar):
@@ -62,13 +64,11 @@ def _post_comment_reply(text, video_id, parent_comment_id, session_token, cookie
}
data = urllib.parse.urlencode(data_dict).encode()
content = util.fetch_url("https://m.youtube.com/service_ajax?name=createCommentReplyEndpoint", headers=headers, data=data, cookiejar_send=cookiejar)
content = util.fetch_url("https://m.youtube.com/service_ajax?name=createCommentReplyEndpoint", headers=headers, data=data, cookiejar_send=cookiejar, debug_name='post_reply')
code = json.loads(content)['code']
print("Comment posting code: " + code)
return code
'''with open('debug/post_comment_response', 'wb') as f:
f.write(content)'''
def _delete_comment(video_id, comment_id, author_id, session_token, cookiejar):
headers = {
@@ -109,108 +109,73 @@ def get_session_token(video_id, cookiejar):
else:
raise Exception("Couldn't find xsrf_token")
def delete_comment(env, start_response):
parameters = env['parameters']
video_id = parameters['video_id'][0]
cookiejar = accounts.account_cookiejar(parameters['channel_id'][0])
@yt_app.route('/delete_comment', methods=['POST'])
def delete_comment():
video_id = request.values['video_id']
cookiejar = accounts.account_cookiejar(request.values['channel_id'])
token = get_session_token(video_id, cookiejar)
code = _delete_comment(video_id, parameters['comment_id'][0], parameters['author_id'][0], token, cookiejar)
code = _delete_comment(video_id, request.values['comment_id'], request.values['author_id'], token, cookiejar)
if code == "SUCCESS":
start_response('303 See Other', [('Location', util.URL_ORIGIN + '/comment_delete_success'),] )
return flask.redirect(util.URL_ORIGIN + '/comment_delete_success', 303)
else:
start_response('303 See Other', [('Location', util.URL_ORIGIN + '/comment_delete_fail'),] )
return flask.redirect(util.URL_ORIGIN + '/comment_delete_fail', 303)
def post_comment(env, start_response):
parameters = env['parameters']
video_id = parameters['video_id'][0]
channel_id = parameters['channel_id'][0]
@yt_app.route('/comment_delete_success')
def comment_delete_success():
return flask.render_template('status.html', title='Success', message='Successfully deleted comment')
@yt_app.route('/comment_delete_fail')
def comment_delete_fail():
return flask.render_template('status.html', title='Error', message='Failed to delete comment')
@yt_app.route('/post_comment', methods=['POST'])
@yt_app.route('/comments', methods=['POST'])
def post_comment():
video_id = request.values['video_id']
channel_id = request.values['channel_id']
cookiejar = accounts.account_cookiejar(channel_id)
token = get_session_token(video_id, cookiejar)
if 'parent_id' in parameters:
code = _post_comment_reply(parameters['comment_text'][0], parameters['video_id'][0], parameters['parent_id'][0], token, cookiejar)
start_response('303 See Other', (('Location', util.URL_ORIGIN + '/comments?' + env['QUERY_STRING']),) )
if 'parent_id' in request.values:
code = _post_comment_reply(request.values['comment_text'], request.values['video_id'], request.values['parent_id'], token, cookiejar)
return flask.redirect(util.URL_ORIGIN + '/comments?' + request.query_string.decode('utf-8'), 303)
else:
code = _post_comment(parameters['comment_text'][0], parameters['video_id'][0], token, cookiejar)
start_response('303 See Other', (('Location', util.URL_ORIGIN + '/comments?ctoken=' + comments.make_comment_ctoken(video_id, sort=1)),) )
code = _post_comment(request.values['comment_text'], request.values['video_id'], token, cookiejar)
return flask.redirect(util.URL_ORIGIN + '/comments?ctoken=' + comments.make_comment_ctoken(video_id, sort=1), 303)
return b''
@yt_app.route('/delete_comment', methods=['GET'])
def get_delete_comment_page():
parameters = [(parameter_name, request.args[parameter_name]) for parameter_name in ('video_id', 'channel_id', 'author_id', 'comment_id')]
return flask.render_template('delete_comment.html', parameters = parameters)
def get_delete_comment_page(env, start_response):
start_response('200 OK', [('Content-type','text/html'),])
parameters = env['parameters']
style = '''
main{
display: grid;
grid-template-columns: minmax(0px, 3fr) 640px 40px 500px minmax(0px,2fr);
align-content: start;
}
main > div, main > form{
margin-top:20px;
grid-column:2;
}
'''
page = '''
<div>Are you sure you want to delete this comment?</div>
<form action="" method="POST">'''
for parameter in ('video_id', 'channel_id', 'author_id', 'comment_id'):
page += '''\n <input type="hidden" name="''' + parameter + '''" value="''' + parameters[parameter][0] + '''">'''
page += '''
<input type="submit" value="Yes, delete it">
</form>'''
return html_common.yt_basic_template.substitute(
page_title = "Delete comment?",
style = style,
header = html_common.get_header(),
page = page,
).encode('utf-8')
def get_post_comment_page(env, start_response):
start_response('200 OK', [('Content-type','text/html'),])
parameters = env['parameters']
video_id = parameters['video_id'][0]
parent_id = util.default_multi_get(parameters, 'parent_id', 0, default='')
@yt_app.route('/post_comment', methods=['GET'])
def get_post_comment_page():
video_id = request.args['video_id']
parent_id = request.args.get('parent_id', '')
style = ''' main{
display: grid;
grid-template-columns: 3fr 2fr;
}
.left{
display:grid;
grid-template-columns: 1fr 640px;
}
textarea{
width: 460px;
height: 85px;
}
.comment-form{
grid-column:2;
justify-content:start;
}'''
if parent_id: # comment reply
comment_box = comments.comment_box_template.substitute(
form_action = util.URL_ORIGIN + '/comments?parent_id=' + parent_id + "&video_id=" + video_id,
video_id_input = '',
post_text = "Post reply",
options=comments.comment_box_account_options(),
)
form_action = util.URL_ORIGIN + '/comments?parent_id=' + parent_id + "&video_id=" + video_id
replying = True
else:
comment_box = comments.comment_box_template.substitute(
form_action = util.URL_ORIGIN + '/post_comment',
video_id_input = '''<input type="hidden" name="video_id" value="''' + video_id + '''">''',
post_text = "Post comment",
options=comments.comment_box_account_options(),
)
page = '''<div class="left">\n''' + comment_box + '''</div>\n'''
return html_common.yt_basic_template.substitute(
page_title = "Post comment reply" if parent_id else "Post a comment",
style = style,
header = html_common.get_header(),
page = page,
).encode('utf-8')
form_action = ''
replying = False
comment_posting_box_info = {
'form_action': form_action,
'video_id': video_id,
'accounts': accounts.account_list_data(),
'include_video_id_input': not replying,
'replying': replying,
}
return flask.render_template('post_comment.html',
comment_posting_box_info = comment_posting_box_info,
replying = replying,
)

View File

@@ -1,16 +1,14 @@
from youtube import util, html_common, yt_data_extract, proto
from youtube import util, yt_data_extract, proto, local_playlist
from youtube import yt_app
import settings
import json
import urllib
import html
from string import Template
import base64
from math import ceil
with open("yt_search_results_template.html", "r") as file:
yt_search_results_template = file.read()
import mimetypes
from flask import request
import flask
# Sort: 1
# Upload date: 2
@@ -55,88 +53,81 @@ def get_search_json(query, page, autocorrect, sort, filters):
'X-YouTube-Client-Version': '2.20180418',
}
url += "&pbj=1&sp=" + page_number_to_sp_parameter(page, autocorrect, sort, filters).replace("=", "%3D")
content = util.fetch_url(url, headers=headers, report_text="Got search results")
content = util.fetch_url(url, headers=headers, report_text="Got search results", debug_name='search_results')
info = json.loads(content)
return info
showing_results_for = Template('''
<div>Showing results for <a>$corrected_query</a></div>
<div>Search instead for <a href="$original_query_url">$original_query</a></div>
''')
did_you_mean = Template('''
<div>Did you mean <a href="$corrected_query_url">$corrected_query</a></div>
''')
def get_search_page(env, start_response):
start_response('200 OK', [('Content-type','text/html'),])
parameters = env['parameters']
if len(parameters) == 0:
return html_common.yt_basic_template.substitute(
page_title = "Search",
header = html_common.get_header(),
style = '',
page = '',
).encode('utf-8')
query = parameters["query"][0]
page = parameters.get("page", "1")[0]
autocorrect = int(parameters.get("autocorrect", "1")[0])
sort = int(parameters.get("sort", "0")[0])
@yt_app.route('/search')
def get_search_page():
if len(request.args) == 0:
return flask.render_template('base.html', title="Search")
if 'query' not in request.args:
abort(400)
query = request.args.get("query")
page = request.args.get("page", "1")
autocorrect = int(request.args.get("autocorrect", "1"))
sort = int(request.args.get("sort", "0"))
filters = {}
filters['time'] = int(parameters.get("time", "0")[0])
filters['type'] = int(parameters.get("type", "0")[0])
filters['duration'] = int(parameters.get("duration", "0")[0])
filters['time'] = int(request.args.get("time", "0"))
filters['type'] = int(request.args.get("type", "0"))
filters['duration'] = int(request.args.get("duration", "0"))
info = get_search_json(query, page, autocorrect, sort, filters)
estimated_results = int(info[1]['response']['estimatedResults'])
estimated_pages = ceil(estimated_results/20)
results = info[1]['response']['contents']['twoColumnSearchResultsRenderer']['primaryContents']['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents']
corrections = ''
result_list_html = ""
parsed_results = []
corrections = {'type': None}
for renderer in results:
type = list(renderer.keys())[0]
if type == 'shelfRenderer':
continue
if type == 'didYouMeanRenderer':
renderer = renderer[type]
corrected_query_string = parameters.copy()
corrected_query_string = request.args.to_dict(flat=False)
corrected_query_string['query'] = [renderer['correctedQueryEndpoint']['searchEndpoint']['query']]
corrected_query_url = util.URL_ORIGIN + '/search?' + urllib.parse.urlencode(corrected_query_string, doseq=True)
corrections = did_you_mean.substitute(
corrected_query_url = corrected_query_url,
corrected_query = yt_data_extract.format_text_runs(renderer['correctedQuery']['runs']),
)
corrections = {
'type': 'did_you_mean',
'corrected_query': yt_data_extract.format_text_runs(renderer['correctedQuery']['runs']),
'corrected_query_url': corrected_query_url,
}
continue
if type == 'showingResultsForRenderer':
renderer = renderer[type]
no_autocorrect_query_string = parameters.copy()
no_autocorrect_query_string = request.args.to_dict(flat=False)
no_autocorrect_query_string['autocorrect'] = ['0']
no_autocorrect_query_url = util.URL_ORIGIN + '/search?' + urllib.parse.urlencode(no_autocorrect_query_string, doseq=True)
corrections = showing_results_for.substitute(
corrected_query = yt_data_extract.format_text_runs(renderer['correctedQuery']['runs']),
original_query_url = no_autocorrect_query_url,
original_query = html.escape(renderer['originalQuery']['simpleText']),
)
corrections = {
'type': 'showing_results_for',
'corrected_query': yt_data_extract.format_text_runs(renderer['correctedQuery']['runs']),
'original_query_url': no_autocorrect_query_url,
'original_query': renderer['originalQuery']['simpleText'],
}
continue
result_list_html += html_common.renderer_html(renderer, current_query_string=env['QUERY_STRING'])
page = int(page)
if page <= 5:
page_start = 1
page_end = min(9, estimated_pages)
else:
page_start = page - 4
page_end = min(page + 4, estimated_pages)
result = Template(yt_search_results_template).substitute(
header = html_common.get_header(query),
results = result_list_html,
page_title = query + " - Search",
search_box_value = html.escape(query),
number_of_results = '{:,}'.format(estimated_results),
number_of_pages = '{:,}'.format(estimated_pages),
page_buttons = html_common.page_buttons_html(page, estimated_pages, util.URL_ORIGIN + "/search", env['QUERY_STRING']),
corrections = corrections
)
return result.encode('utf-8')
info = yt_data_extract.parse_info_prepare_for_html(renderer)
if info['type'] != 'unsupported':
parsed_results.append(info)
return flask.render_template('search.html',
header_playlist_names = local_playlist.get_playlist_names(),
query = query,
estimated_results = estimated_results,
estimated_pages = estimated_pages,
corrections = corrections,
results = parsed_results,
parameters_dictionary = request.args,
)
@yt_app.route('/opensearch.xml')
def get_search_engine_xml():
with open("youtube/opensearch.xml", 'rb') as f:
content = f.read().replace(b'$port_number', str(settings.port_number).encode())
return flask.Response(content, mimetype='application/xml')

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -222,6 +222,12 @@ address{
max-height:2.4em;
overflow:hidden;
}
.medium-item .stats > *::after{
content: " | ";
}
.medium-item .stats > *:last-child::after{
content: "";
}
.medium-item .description{
grid-column: 2 / span 2;

View File

@@ -1,132 +0,0 @@
import re as _re
from collections import ChainMap as _ChainMap
class _TemplateMetaclass(type):
pattern = r"""
%(delim)s(?:
(?P<escaped>%(delim)s) | # Escape sequence of two delimiters
(?P<named>%(id)s) | # delimiter and a Python identifier
{(?P<braced>%(id)s)} | # delimiter and a braced identifier
(?P<invalid>) # Other ill-formed delimiter exprs
)
"""
def __init__(cls, name, bases, dct):
super(_TemplateMetaclass, cls).__init__(name, bases, dct)
if 'pattern' in dct:
pattern = cls.pattern
else:
pattern = _TemplateMetaclass.pattern % {
'delim' : _re.escape(cls.delimiter),
'id' : cls.idpattern,
}
cls.pattern = _re.compile(pattern, cls.flags | _re.VERBOSE)
class Template(metaclass=_TemplateMetaclass):
"""A string class for supporting $-substitutions."""
delimiter = '$'
idpattern = r'[_a-z][_a-z0-9]*'
flags = _re.IGNORECASE
def __init__(self, template):
self.template = template
# Search for $$, $identifier, ${identifier}, and any bare $'s
def _invalid(self, mo):
i = mo.start('invalid')
lines = self.template[:i].splitlines(keepends=True)
if not lines:
colno = 1
lineno = 1
else:
colno = i - len(''.join(lines[:-1]))
lineno = len(lines)
raise ValueError('Invalid placeholder in string: line %d, col %d' %
(lineno, colno))
def substitute(*args, **kws):
if not args:
raise TypeError("descriptor 'substitute' of 'Template' object "
"needs an argument")
self, *args = args # allow the "self" keyword be passed
if len(args) > 1:
raise TypeError('Too many positional arguments')
if not args:
mapping = kws
elif kws:
mapping = _ChainMap(kws, args[0])
else:
mapping = args[0]
# Helper function for .sub()
def convert(mo):
# Check the most common path first.
named = mo.group('named') or mo.group('braced')
if named is not None:
return str(mapping.get(named,''))
if mo.group('escaped') is not None:
return self.delimiter
if mo.group('invalid') is not None:
self._invalid(mo)
raise ValueError('Unrecognized named group in pattern',
self.pattern)
return self.pattern.sub(convert, self.template)
def strict_substitute(*args, **kws):
if not args:
raise TypeError("descriptor 'substitute' of 'Template' object "
"needs an argument")
self, *args = args # allow the "self" keyword be passed
if len(args) > 1:
raise TypeError('Too many positional arguments')
if not args:
mapping = kws
elif kws:
mapping = _ChainMap(kws, args[0])
else:
mapping = args[0]
# Helper function for .sub()
def convert(mo):
# Check the most common path first.
named = mo.group('named') or mo.group('braced')
if named is not None:
return str(mapping[named])
if mo.group('escaped') is not None:
return self.delimiter
if mo.group('invalid') is not None:
self._invalid(mo)
raise ValueError('Unrecognized named group in pattern',
self.pattern)
return self.pattern.sub(convert, self.template)
def safe_substitute(*args, **kws):
if not args:
raise TypeError("descriptor 'safe_substitute' of 'Template' object "
"needs an argument")
self, *args = args # allow the "self" keyword be passed
if len(args) > 1:
raise TypeError('Too many positional arguments')
if not args:
mapping = kws
elif kws:
mapping = _ChainMap(kws, args[0])
else:
mapping = args[0]
# Helper function for .sub()
def convert(mo):
named = mo.group('named') or mo.group('braced')
if named is not None:
try:
return str(mapping[named])
except KeyError:
return mo.group()
if mo.group('escaped') is not None:
return self.delimiter
if mo.group('invalid') is not None:
return mo.group()
raise ValueError('Unrecognized named group in pattern',
self.pattern)
return self.pattern.sub(convert, self.template)

114
youtube/templates/base.html Normal file
View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{ page_title }}</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline'; script-src 'none'; media-src 'self' https://*.googlevideo.com">
<link href="/youtube.com/static/shared.css" type="text/css" rel="stylesheet">
<link href="/youtube.com/static/comments.css" type="text/css" rel="stylesheet">
<link href="/youtube.com/static/favicon.ico" type="image/x-icon" rel="icon">
<link title="Youtube local" href="/youtube.com/opensearch.xml" rel="search" type="application/opensearchdescription+xml">
<style type="text/css">
{% block style %}
{{ style }}
{% endblock %}
</style>
</head>
<body>
<header>
<form id="site-search" action="/youtube.com/search">
<input type="search" name="query" class="search-box" value="{{ search_box_value }}">
<button type="submit" value="Search" class="search-button">Search</button>
<div class="dropdown">
<button class="dropdown-label">Options</button>
<div class="css-sucks">
<div class="dropdown-content">
<h3>Sort by</h3>
<input type="radio" id="sort_relevance" name="sort" value="0">
<label for="sort_relevance">Relevance</label>
<input type="radio" id="sort_upload_date" name="sort" value="2">
<label for="sort_upload_date">Upload date</label>
<input type="radio" id="sort_view_count" name="sort" value="3">
<label for="sort_view_count">View count</label>
<input type="radio" id="sort_rating" name="sort" value="1">
<label for="sort_rating">Rating</label>
<h3>Upload date</h3>
<input type="radio" id="time_any" name="time" value="0">
<label for="time_any">Any</label>
<input type="radio" id="time_last_hour" name="time" value="1">
<label for="time_last_hour">Last hour</label>
<input type="radio" id="time_today" name="time" value="2">
<label for="time_today">Today</label>
<input type="radio" id="time_this_week" name="time" value="3">
<label for="time_this_week">This week</label>
<input type="radio" id="time_this_month" name="time" value="4">
<label for="time_this_month">This month</label>
<input type="radio" id="time_this_year" name="time" value="5">
<label for="time_this_year">This year</label>
<h3>Type</h3>
<input type="radio" id="type_any" name="type" value="0">
<label for="type_any">Any</label>
<input type="radio" id="type_video" name="type" value="1">
<label for="type_video">Video</label>
<input type="radio" id="type_channel" name="type" value="2">
<label for="type_channel">Channel</label>
<input type="radio" id="type_playlist" name="type" value="3">
<label for="type_playlist">Playlist</label>
<input type="radio" id="type_movie" name="type" value="4">
<label for="type_movie">Movie</label>
<input type="radio" id="type_show" name="type" value="5">
<label for="type_show">Show</label>
<h3>Duration</h3>
<input type="radio" id="duration_any" name="duration" value="0">
<label for="duration_any">Any</label>
<input type="radio" id="duration_short" name="duration" value="1">
<label for="duration_short">Short (&lt; 4 minutes)</label>
<input type="radio" id="duration_long" name="duration" value="2">
<label for="duration_long">Long (&gt; 20 minutes)</label>
</div>
</div>
</div>
</form>
<div id="header-right">
<form id="playlist-edit" action="/youtube.com/edit_playlist" method="post" target="_self">
<input name="playlist_name" id="playlist-name-selection" list="playlist-options" type="text">
<datalist id="playlist-options">
{% for playlist_name in header_playlist_names %}
<option value="{{ playlist_name }}">{{ playlist_name }}</option>
{% endfor %}
</datalist>
<button type="submit" id="playlist-add-button" name="action" value="add">Add to playlist</button>
<button type="reset" id="item-selection-reset">Clear selection</button>
</form>
<a href="/youtube.com/playlists" id="local-playlists">Local playlists</a>
</div>
</header>
<main>
{% block main %}
{{ main }}
{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,160 @@
{% set page_title = channel_name + ' - Channel' %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
main{
display:grid;
{% if current_tab == 'about' %}
grid-template-rows: 0fr 0fr 1fr;
grid-template-columns: 0fr 1fr;
{% else %}
grid-template-rows: repeat(5, 0fr);
grid-template-columns: auto 1fr;
{% endif %}
}
main .avatar{
grid-row:1;
grid-column:1;
height:200px;
width:200px;
}
main .summary{
grid-row:1;
grid-column:2;
margin-left: 5px;
}
main .channel-tabs{
grid-row:2;
grid-column: 1 / span 2;
display:grid;
grid-auto-flow: column;
justify-content:start;
background-color: #aaaaaa;
padding: 3px;
padding-left: 6px;
}
#links-metadata{
display: grid;
grid-auto-flow: column;
grid-column-gap: 10px;
grid-column: 1/span 2;
justify-content: start;
padding-top: 8px;
padding-bottom: 8px;
padding-left: 6px;
background-color: #bababa;
margin-bottom: 10px;
}
#number-of-results{
font-weight:bold;
}
.item-grid{
padding-left: 20px;
grid-row:4;
grid-column: 1 / span 2;
}
.item-list{
width:1000px;
grid-column: 1 / span 2;
}
.page-button-row{
grid-column: 1 / span 2;
}
.tab{
padding: 5px 75px;
}
.channel-info{
grid-row: 3;
grid-column: 1 / span 3;
}
.channel-info ul{
padding-left: 40px;
}
.channel-info h3{
margin-left: 40px;
}
.channel-info .description{
white-space: pre-wrap;
min-width: 0;
margin-left: 40px;
}
.medium-item img{
max-width: 168px;
}
{% endblock style %}
{% block main %}
<img class="avatar" src="{{ avatar }}">
<div class="summary">
<h2 class="title">{{ channel_name }}</h2>
<p class="short-description">{{ short_description }}</p>
</div>
<nav class="channel-tabs">
{% for tab_name in ('Videos', 'Playlists', 'About') %}
{% if tab_name.lower() == current_tab %}
<a class="tab page-button">{{ tab_name }}</a>
{% else %}
<a class="tab page-button" href="{{ channel_url + '/' + tab_name.lower() }}">{{ tab_name }}</a>
{% endif %}
{% endfor %}
<form class="channel-search" action="{{ channel_url + '/search' }}">
<input type="search" name="query" class="search-box" value="{{ search_box_value }}">
<button type="submit" value="Search" class="search-button">Search</button>
</form>
</nav>
{% if current_tab == 'about' %}
<div class="channel-info">
<ul>
{% for stat in stats %}
<li>{{ stat }}</li>
{% endfor %}
</ul>
<hr>
<h3>Description</h3>
<div class="description">{{ common_elements.text_runs(description) }}</div>
<hr>
<ul>
{% for text, url in links %}
<li><a href="{{ url }}">{{ text }}</a></li>
{% endfor %}
</ul>
</div>
{% else %}
<div id="links-metadata">
{% if current_tab == 'videos' %}
{% set sorts = [('1', 'views'), ('2', 'oldest'), ('3', 'newest')] %}
<div id="number-of-results">{{ number_of_videos }} videos</div>
{% elif current_tab == 'playlists' %}
{% set sorts = [('2', 'oldest'), ('3', 'newest'), ('4', 'last video added')] %}
{% else %}
{% set sorts = [] %}
{% endif %}
{% for sort_number, sort_name in sorts %}
{% if sort_number == current_sort.__str__() %}
<a class="sort-button">{{ 'Sorted by ' + sort_name }}</a>
{% else %}
<a class="sort-button" href="{{ channel_url + '/' + current_tab + '?sort=' + sort_number }}">{{ 'Sort by ' + sort_name }}</a>
{% endif %}
{% endfor %}
</div>
{% if current_tab != 'about' %}
<nav class="{{ 'item-list' if current_tab == 'search' else 'item-grid' }}">
{% for item_info in items %}
{{ common_elements.item(item_info, include_author=false) }}
{% endfor %}
</nav>
{% if current_tab != 'playlists' %}
<nav class="page-button-row">
{{ common_elements.page_buttons(number_of_pages, channel_url + '/' + current_tab, parameters_dictionary) }}
</nav>
{% endif %}
{% endif %}
{% endif %}
{% endblock main %}

View File

@@ -0,0 +1,70 @@
{% import "common_elements.html" as common_elements %}
{% macro render_comment(comment, include_avatar) %}
<div class="comment-container">
<div class="comment">
<a class="author-avatar" href="{{ comment['author_url'] }}" title="{{ comment['author'] }}">
{% if include_avatar %}
<img class="author-avatar-img" src="{{ comment['author_avatar'] }}">
{% endif %}
</a>
<address>
<a class="author" href="{{ comment['author_url'] }}" title="{{ comment['author'] }}">{{ comment['author'] }}</a>
</address>
<a class="permalink" href="{{ comment['permalink'] }}" title="permalink">
<time datetime="">{{ comment['published'] }}</time>
</a>
<span class="text">{{ common_elements.text_runs(comment['text']) }}</span>
<span class="likes">{{ comment['likes_text'] if comment['likes'] else ''}}</span>
<div class="bottom-row">
<a href="{{ comment['replies_url'] }}" class="replies">{{ comment['view_replies_text'] }}</a>
{% if 'delete_url' is in comment %}
<a href="{{ comment['delete_url'] }}" target="_blank">Delete</a>
{% endif %}
</div>
</div>
</div>
{% endmacro %}
{% macro video_comments(comments_info) %}
<section class="comments-area">
<div class="comment-links">
{% for link_text, link_url in comments_info['comment_links'] %}
<a class="sort-button" href="{{ link_url }}">{{ link_text }}</a>
{% endfor %}
</div>
<div class="comments">
{% for comment in comments_info['comments'] %}
{{ render_comment(comment, comments_info['include_avatars']) }}
{% 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>
{% endif %}
</section>
{% endmacro %}
{% macro comment_posting_box(info) %}
<form action="{{ info['form_action'] }}" method="post" class="comment-form">
<div id="comment-account-options">
<label for="account-selection">Account:</label>
<select id="account-selection" name="channel_id">
{% for account in info['accounts'] %}
<option value="{{ account[0] }}">{{ account[1] }}</option>
{% endfor %}
</select>
<a href="/https://youtube.com/login" target="_blank">Add account</a>
</div>
<textarea name="comment_text"></textarea>
{% if info['include_video_id_input'] %}
<input type="hidden" name="video_id" value="{{ info['video_id'] }}">
{% endif %}
<button type="submit" class="post-comment-button">{{ 'Post reply' if info['replying'] else 'Post comment' }}</button>
</form>
{% endmacro %}

View File

@@ -0,0 +1,65 @@
{% set page_title = ('Replies' if comments_info['is_replies'] else 'Comments page ' + comments_info['page_number']) %}
{% extends "base.html" %}
{% import "comments.html" as comments %}
{% block style %}
main{
display:grid;
grid-template-columns: 3fr 2fr;
}
#left{
background-color:#bcbcbc;
display: grid;
grid-column: 1;
grid-row: 1;
grid-template-columns: 1fr 640px;
grid-template-rows: 0fr 0fr 0fr;
}
.comments-area{
grid-column:2;
}
.comment{
width:640px;
}
{% endblock style %}
{% block main %}
<div id="left">
<section class="comments-area">
{% if not comments_info['is_replies'] %}
<section class="video-metadata">
<a class="video-metadata-thumbnail-box" href="{{ comments_info['video_url'] }}" title="{{ comments_info['video_title'] }}">
<img class="video-metadata-thumbnail-img" src="{{ comments_info['video_thumbnail'] }}" height="180px" width="320px">
</a>
<a class="title" href="{{ comments_info['video_url'] }}" title="{{ comments_info['video_title'] }}">{{ comments_info['video_title'] }}</a>
<h2>Comments page {{ comments_info['page_number'] }}</h2>
<span>Sorted by {{ comments_info['sort_text'] }}</span>
</section>
{% endif %}
{{ comments.comment_posting_box(comment_posting_box_info) }}
{% if not comments_info['is_replies'] %}
<div class="comment-links">
{% for link_text, link_url in comments_info['comment_links'] %}
<a class="sort-button" href="{{ link_url }}">{{ link_text }}</a>
{% endfor %}
</div>
{% endif %}
<div class="comments">
{% for comment in comments_info['comments'] %}
{{ comments.render_comment(comment, comments_info['include_avatars']) }}
{% 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>
{% endif %}
</section>
</div>
{% endblock main %}

View File

@@ -0,0 +1,154 @@
{% macro text_runs(runs) %}
{%- if runs[0] is mapping -%}
{%- for text_run in runs -%}
{%- if text_run.get("bold", false) -%}
<b>{{ text_run["text"] }}</b>
{%- elif text_run.get('italics', false) -%}
<i>{{ text_run["text"] }}</i>
{%- else -%}
{{ text_run["text"] }}
{%- endif -%}
{%- endfor -%}
{%- else -%}
{{ runs }}
{%- endif -%}
{% endmacro %}
{% macro small_item(info, include_author=true) %}
<div class="small-item-box">
<div class="small-item">
{% if info['type'] == 'video' %}
<a class="video-thumbnail-box" href="{{ info['url'] }}" title="{{ info['title'] }}">
<img class="video-thumbnail-img" src="{{ info['thumbnail'] }}">
<span class="video-duration">{{ info['duration'] }}</span>
</a>
<a class="title" href="{{ info['url'] }}" title="{{ info['title'] }}">{{ info['title'] }}</a>
<address>{{ info['author'] }}</address>
<span class="views">{{ info['views'] }}</span>
{% elif info['type'] == 'playlist' %}
<a class="playlist-thumbnail-box" href="{{ info['url'] }}" title="{{ info['title'] }}">
<img class="playlist-thumbnail-img" src="{{ info['thumbnail'] }}">
<div class="playlist-thumbnail-info">
<span>{{ info['size'] }}</span>
</div>
</a>
<a class="title" href="{{ info['url'] }}" title="{{ info['title'] }}">{{ info['title'] }}</a>
<address>{{ info['author'] }}</address>
{% else %}
Error: unsupported item type
{% endif %}
</div>
{% if info['type'] == 'video' %}
<input class="item-checkbox" type="checkbox" name="video_info_list" value="{{ info['video_info'] }}" form="playlist-edit">
{% endif %}
</div>
{% endmacro %}
{% macro get_stats(info, include_author=true) %}
{% if include_author %}
{% if 'author_url' is in(info) %}
<address>By <a href="{{ info['author_url'] }}">{{ info['author'] }}</a></address>
{% else %}
<address><b>{{ info['author'] }}</b></address>
{% endif %}
{% endif %}
{% if 'views' is in(info) %}
<span class="views">{{ info['views'] }}</span>
{% endif %}
{% if 'published' is in(info) %}
<time>{{ info['published'] }}</time>
{% endif %}
{% endmacro %}
{% macro medium_item(info, include_author=true) %}
<div class="medium-item-box">
<div class="medium-item">
{% if info['type'] == 'video' %}
<a class="video-thumbnail-box" href="{{ info['url'] }}" title="{{ info['title'] }}">
<img class="video-thumbnail-img" src="{{ info['thumbnail'] }}">
<span class="video-duration">{{ info['duration'] }}</span>
</a>
<a class="title" href="{{ info['url'] }}" title="{{ info['title'] }}">{{ info['title'] }}</a>
<div class="stats">
{{ get_stats(info, include_author) }}
</div>
<span class="description">{{ text_runs(info.get('description', '')) }}</span>
<span class="badges">{{ info['badges']|join(' | ') }}</span>
{% elif info['type'] == 'playlist' %}
<a class="playlist-thumbnail-box" href="{{ info['url'] }}" title="{{ info['title'] }}">
<img class="playlist-thumbnail-img" src="{{ info['thumbnail'] }}">
<div class="playlist-thumbnail-info">
<span>{{ info['size'] }}</span>
</div>
</a>
<a class="title" href="{{ info['url'] }}" title="{{ info['title'] }}">{{ info['title'] }}</a>
<div class="stats">
{{ get_stats(info, include_author) }}
</div>
{% elif info['type'] == 'channel' %}
<a class="video-thumbnail-box" href="{{ info['url'] }}" title="{{ info['title'] }}">
<img class="video-thumbnail-img" src="{{ info['thumbnail'] }}">
</a>
<a class="title" href="{{ info['url'] }}">{{ info['title'] }}</a>
<span>{{ info['subscriber_count'] }} subscribers</span>
<span>{{ info['size'] }} videos</span>
<span class="description">{{ text_runs(info.get('description', '')) }}</span>
{% else %}
Error: unsupported item type
{% endif %}
</div>
{% if info['type'] == 'video' %}
<input class="item-checkbox" type="checkbox" name="video_info_list" value="{{ info['video_info'] }}" form="playlist-edit">
{% endif %}
</div>
{% endmacro %}
{% macro item(info, include_author=true) %}
{% if info['item_size'] == 'small' %}
{{ small_item(info, include_author) }}
{% elif info['item_size'] == 'medium' %}
{{ medium_item(info, include_author) }}
{% else %}
Error: Unknown item size
{% endif %}
{% endmacro %}
{% macro page_buttons(estimated_pages, url, parameters_dictionary) %}
{% set current_page = parameters_dictionary.get('page', 1)|int %}
{% set parameters_dictionary = parameters_dictionary.to_dict() %}
{% if current_page is le(5) %}
{% set page_start = 1 %}
{% set page_end = [9, estimated_pages]|min %}
{% else %}
{% set page_start = current_page - 4 %}
{% set page_end = [current_page + 4, estimated_pages]|min %}
{% endif %}
{% for page in range(page_start, page_end+1) %}
{% if page == current_page %}
<div class="page-button">{{ page }}</div>
{% else %}
{# IMPORTANT: Jinja SUCKS #}
{# https://stackoverflow.com/questions/36886650/how-to-add-a-new-entry-into-a-dictionary-object-while-using-jinja2 #}
{% set _ = parameters_dictionary.__setitem__('page', page) %}
<a class="page-button" href="{{ url + '?' + parameters_dictionary|urlencode }}">{{ page }}</a>
{% endif %}
{% endfor %}
{% endmacro %}

View File

@@ -0,0 +1,25 @@
{% set page_title = 'Delete comment?' %}
{% extends "base.html" %}
{% block style %}
main{
display: grid;
grid-template-columns: minmax(0px, 3fr) 640px 40px 500px minmax(0px,2fr);
align-content: start;
}
main > div, main > form{
margin-top:20px;
grid-column:2;
}
{% endblock style %}
{% block main %}
<div>Are you sure you want to delete this comment?</div>
<form action="" method="POST">
{% for parameter_name, parameter_value in parameters %}
<input type="hidden" name="{{ parameter_name }}" value="{{ parameter_value }}">
{% endfor %}
<input type="submit" value="Yes, delete it">
</form>
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% set page_title = 'Error' %}
{% extends "base.html" %}
{% block main %}
{{ error_message }}
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% set page_title = playlist_name + ' - Local playlist' %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
main{
display:grid;
grid-template-columns: 3fr 1fr;
}
#left{
grid-column: 1;
grid-row: 1;
display: grid;
grid-template-columns: 1fr 800px auto;
grid-template-rows: 0fr 1fr 0fr;
}
.playlist-title{
grid-column:2;
}
#playlist-remove-button{
grid-column:3;
align-self: center;
white-space: nowrap;
}
#results{
grid-row: 2;
grid-column: 2 / span 2;
display: grid;
grid-auto-rows: 0fr;
grid-row-gap: 10px;
}
.page-button-row{
grid-row: 3;
grid-column: 2;
justify-self: center;
}
{% endblock style %}
{% block main %}
<div id="left">
<h2 class="playlist-title">{{ playlist_name }}</h2>
<input type="hidden" name="playlist_page" value="{{ playlist_name }}" form="playlist-edit">
<button type="submit" id="playlist-remove-button" name="action" value="remove" form="playlist-edit" formaction="">Remove from playlist</button>
<div id="results">
{% for video_info in videos %}
{{ common_elements.item(video_info) }}
{% endfor %}
</div>
<nav class="page-button-row">
{{ common_elements.page_buttons(num_pages, '/https://www.youtube.com/playlists/' + playlist_name, parameters_dictionary) }}
</nav>
</div>
{% endblock main %}

View File

@@ -0,0 +1,16 @@
{% set page_title = 'Local playlists' %}
{% extends "base.html" %}
{% block main %}
<ul>
{% for playlist_name, playlist_url in playlists %}
<li><a href="{{ playlist_url }}">{{ playlist_name }}</a></li>
{% endfor %}
</ul>
{% endblock main %}

View File

@@ -0,0 +1,60 @@
{% set page_title = 'Login' %}
{% extends "base.html" %}
{% block style %}
main{
display: grid;
grid-template-columns: minmax(0px, 3fr) 640px 40px 500px minmax(0px,2fr);
align-content: start;
grid-row-gap: 40px;
}
main form{
margin-top:20px;
grid-column:2;
display:grid;
justify-items: start;
align-content: start;
grid-row-gap: 10px;
}
#username, #password{
grid-column:2;
width: 250px;
}
#add-account-button{
margin-top:20px;
}
#tor-note{
grid-row:2;
grid-column:2;
background-color: #dddddd;
padding: 10px;
}
{% endblock style %}
{% block main %}
<form action="" method="POST">
<div class="form-field">
<label for="username">Username:</label>
<input type="text" id="username" name="username">
</div>
<div class="form-field">
<label for="password">Password:</label>
<input type="password" id="password" name="password">
</div>
<div id="save-account-checkbox">
<input type="checkbox" id="save-account" name="save" checked>
<label for="save-account">Save account info to disk (password will not be saved, only the login cookie)</label>
</div>
<div>
<input type="checkbox" id="use-tor" name="use_tor">
<label for="use-tor">Use Tor when logging in (WARNING: This will lock your Google account under normal circumstances, see note below)</label>
</div>
<input type="submit" value="Add account" id="add-account-button">
</form>
<div id="tor-note"><b>Note on using Tor to log in</b><br>
Using Tor to log in should only be done if the account was created using a proxy/VPN/Tor to begin with and hasn't been logged in using your IP. Otherwise, it's pointless since Google already knows who the account belongs to. When logging into a google account, it must be logged in using an IP address geographically close to the area where the account was created or where it is logged into regularly. If the account was created using an IP address in America and is logged into from an IP in Russia, Google will block the Russian IP from logging in, assume someone knows your password, lock the account, and make you change your password. If creating an account using Tor, you must remember the IP (or geographic region) it was created in, and only log in using that geographic region for the exit node. This can be accomplished by <a href="https://tor.stackexchange.com/questions/733/can-i-exit-from-a-specific-country-or-node">putting the desired IP in the torrc file</a> to force Tor to use that exit node. Using the login cookie to post comments through Tor is perfectly safe, however.
</div>
{% endblock main %}

View File

@@ -0,0 +1,106 @@
{% set page_title = title + ' - Page ' + parameters_dictionary.get('page', '1') %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
main{
display:grid;
grid-template-columns: 3fr 1fr;
}
#left{
grid-column: 1;
grid-row: 1;
display: grid;
grid-template-columns: 1fr 800px;
grid-template-rows: 0fr 1fr 0fr;
}
.playlist-metadata{
grid-column:2;
grid-row:1;
display:grid;
grid-template-columns: 0fr 1fr;
}
.playlist-thumbnail{
grid-row: 1 / span 5;
grid-column:1;
justify-self:start;
width:250px;
margin-right: 10px;
}
.playlist-title{
grid-row: 1;
grid-column:2;
}
.playlist-author{
grid-row:2;
grid-column:2;
}
.playlist-stats{
grid-row:3;
grid-column:2;
}
.playlist-description{
grid-row:4;
grid-column:2;
min-width:0px;
white-space: pre-line;
}
.page-button-row{
grid-row: 3;
grid-column: 2;
justify-self: center;
}
#right{
grid-column: 2;
grid-row: 1;
}
#results{
grid-row: 2;
grid-column: 2;
margin-top:10px;
display: grid;
grid-auto-rows: 0fr;
grid-row-gap: 10px;
}
{% endblock style %}
{% block main %}
<div id="left">
<div class="playlist-metadata">
<img class="playlist-thumbnail" src="{{ thumbnail }}">
<h2 class="playlist-title">{{ title }}</h2>
<a class="playlist-author" href="{{ author_url }}">{{ author }}</a>
<div class="playlist-stats">
<div>{{ views }}</div>
<div>{{ size }}</div>
</div>
<div class="playlist-description">{{ common_elements.text_runs(description) }}</div>
</div>
<div id="results">
{% for info in video_list %}
{{ common_elements.item(info) }}
{% endfor %}
</div>
<nav class="page-button-row">
{{ common_elements.page_buttons(num_pages, '/https://www.youtube.com/playlist', parameters_dictionary) }}
</nav>
</div>
{% endblock main %}

View File

@@ -0,0 +1,29 @@
{% set page_title = 'Post reply' if replying else 'Post comment' %}
{% extends "base.html" %}
{% import "comments.html" as comments %}
{% block style %}
main{
display: grid;
grid-template-columns: 3fr 2fr;
}
.left{
display:grid;
grid-template-columns: 1fr 640px;
}
textarea{
width: 460px;
height: 85px;
}
.comment-form{
grid-column:2;
justify-content:start;
}
{% endblock style %}
{% block main %}
<div class="left">
{{ comments.comment_posting_box(comment_posting_box_info) }}
</div>
{% endblock %}

View File

@@ -0,0 +1,54 @@
{% set search_box_value = query %}
{% set page_title = query + ' - Search' %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
main{
display:grid;
grid-template-columns: minmax(0px, 1fr) 800px minmax(0px,2fr);
max-width:100vw;
}
#number-of-results{
font-weight:bold;
}
#result-info{
grid-row: 1;
grid-column:2;
align-self:center;
}
.page-button-row{
grid-column: 2;
justify-self: center;
}
.item-list{
grid-row: 2;
grid-column: 2;
}
.badge{
background-color:#cccccc;
}
{% endblock style %}
{% block main %}
<div id="result-info">
<div id="number-of-results">Approximately {{ '{:,}'.format(estimated_results) }} results ({{ '{:,}'.format(estimated_pages) }} pages)</div>
{% if corrections['type'] == 'showing_results_for' %}
<div>Showing results for <a>{{ corrections['corrected_query']|safe }}</a></div>
<div>Search instead for <a href="{{ corrections['original_query_url'] }}">{{ corrections['original_query'] }}</a></div>
{% elif corrections['type'] == 'did_you_mean' %}
<div>Did you mean <a href="{{ corrections['corrected_query_url'] }}">{{ corrections['corrected_query']|safe }}</a></div>
{% endif %}
</div>
<div class="item-list">
{% for info in results %}
{{ common_elements.item(info) }}
{% endfor %}
</div>
<nav class="page-button-row">
{{ common_elements.page_buttons(estimated_pages, '/https://www.youtube.com/search', parameters_dictionary) }}
</nav>
{% endblock main %}

View File

@@ -0,0 +1,7 @@
{% set page_title = (title if (title is defined) else 'Status') %}
{% extends "base.html" %}
{% block main %}
{{ message }}
{% endblock %}

View File

@@ -0,0 +1,230 @@
{% set page_title = title %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% import "comments.html" as comments %}
{% block style %}
main{
display:grid;
grid-template-columns: minmax(0px, 3fr) 640px 40px 500px minmax(0px,2fr);
background-color:#cccccc;
}
#left{
background-color:#bcbcbc;
grid-column: 1;
}
.full-item{
display: grid;
grid-column: 2;
grid-template-rows: 0fr 0fr 0fr 0fr 20px 0fr 0fr;
grid-template-columns: 1fr 1fr;
align-content: start;
background-color:#bcbcbc;
}
.full-item > video{
grid-column: 1 / span 2;
grid-row: 1;
}
.full-item > .title{
grid-column: 1 / span 2;
grid-row:2;
min-width: 0;
}
.full-item > .is-unlisted{
background-color: #d0d0d0;
justify-self:start;
padding-left:2px;
padding-right:2px;
}
.full-item > address{
grid-column: 1;
grid-row: 4;
justify-self: start;
}
.full-item > .views{
grid-column: 2;
grid-row: 4;
justify-self:end;
}
.full-item > time{
grid-column: 1;
grid-row: 5;
justify-self:start;
}
.full-item > .likes-dislikes{
grid-column: 2;
grid-row: 5;
justify-self:end;
}
.full-item > .download-dropdown{
grid-column:1;
grid-row: 6;
}
.full-item > .checkbox{
justify-self:end;
grid-row: 6;
grid-column: 2;
}
.full-item > .description{
background-color:#d0d0d0;
margin-top:8px;
white-space: pre-wrap;
min-width: 0;
word-wrap: break-word;
grid-column: 1 / span 2;
grid-row: 7;
}
.full-item .music-list{
grid-row:8;
grid-column: 1 / span 2;
}
.full-item .comments-area{
grid-column: 1 / span 2;
grid-row: 9;
margin-top:10px;
}
.comment{
width:640px;
}
.music-list{
background-color: #d0d0d0;
}
.music-list table,th,td{
border: 1px solid;
}
.music-list th,td{
padding-left:4px;
padding-right:5px;
}
.music-list caption{
text-align:left;
font-weight:bold;
margin-bottom:5px;
}
#related{
grid-column: 4;
display: grid;
grid-auto-rows: 90px;
grid-row-gap: 10px;
}
#related .medium-item{
grid-template-columns: 160px 1fr 0fr;
}
.download-dropdown{
z-index:1;
justify-self:start;
min-width:0px;
height:0px;
}
.download-dropdown-label{
background-color: #e9e9e9;
border-style: outset;
border-width: 2px;
font-weight: bold;
}
.download-dropdown-content{
display:none;
background-color: #e9e9e9;
}
.download-dropdown:hover .download-dropdown-content {
display: grid;
grid-auto-rows:30px;
padding-bottom: 50px;
}
.download-dropdown-content a{
white-space: nowrap;
display:grid;
grid-template-columns: 60px 90px auto;
max-height: 1.2em;
}
{% endblock style %}
{% block main %}
<div id="left">
</div>
<article class="full-item">
<video width="640" height="360" controls autofocus>
{% for video_source in video_sources %}
<source src="{{ video_source['src'] }}" type="{{ video_source['type'] }}">
{% endfor %}
{% for source in subtitle_sources %}
{% if source['on'] %}
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}" default>
{% else %}
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}">
{% endif %}
{% endfor %}
</video>
<h2 class="title">{{ title }}</h2>
{% if unlisted %}
<span class="is-unlisted">Unlisted</span>
{% endif %}
<address>Uploaded by <a href="{{ uploader_channel_url }}">{{ uploader }}</a></address>
<span class="views">{{ views }} views</span>
<time datetime="$upload_date">Published on {{ upload_date }}</time>
<span class="likes-dislikes">{{ likes }} likes {{ dislikes }} dislikes</span>
<div class="download-dropdown">
<button class="download-dropdown-label">Download</button>
<div class="download-dropdown-content">
{% for format in download_formats %}
<a href="{{ format['url'] }}">
<span>{{ format['ext'] }}</span>
<span>{{ format['resolution'] }}</span>
<span>{{ format['note'] }}</span>
</a>
{% endfor %}
</div>
</div>
<input class="checkbox" name="video_info_list" value="{{ video_info }}" form="playlist-edit" type="checkbox">
<span class="description">{{ description }}</span>
<div class="music-list">
{% if music_list.__len__() != 0 %}
<hr>
<table>
<caption>Music</caption>
<tr>
{% for attribute in music_attributes %}
<th>{{ attribute }}</th>
{% endfor %}
</tr>
{% for track in music_list %}
<tr>
{% for attribute in music_attributes %}
<td>{{ track.get(attribute.lower(), '') }}</td>
{% endfor %}
</tr>
{% endfor %}
</table>
{% endif %}
</div>
{% if comments_info %}
{{ comments.video_comments(comments_info) }}
{% endif %}
</article>
<nav id="related">
{% for info in related %}
{{ common_elements.item(info) }}
{% endfor %}
</nav>
{% endblock main %}

View File

@@ -107,7 +107,7 @@ def decode_content(content, encoding_header):
content = gzip.decompress(content)
return content
def fetch_url(url, headers=(), timeout=15, report_text=None, data=None, cookiejar_send=None, cookiejar_receive=None, use_tor=True, return_response=False):
def fetch_url(url, headers=(), timeout=15, report_text=None, data=None, cookiejar_send=None, cookiejar_receive=None, use_tor=True, return_response=False, debug_name=None):
'''
When cookiejar_send is set to a CookieJar object,
those cookies will be sent in the request (but cookies in response will not be merged into it)
@@ -164,6 +164,14 @@ def fetch_url(url, headers=(), timeout=15, report_text=None, data=None, cookieja
print(report_text, ' Latency:', round(response_time - start_time,3), ' Read time:', round(read_finish - response_time,3))
content = decode_content(content, response.getheader('Content-Encoding', default='identity'))
if settings.debugging_save_responses and debug_name is not None:
save_dir = os.path.join(settings.data_dir, 'debug')
if not os.path.exists(save_dir):
os.makedirs(save_dir)
with open(os.path.join(save_dir, debug_name), 'wb') as f:
f.write(content)
if return_response:
return content, response
return content
@@ -308,4 +316,4 @@ def update_query_string(query_string, items):
def uppercase_escape(s):
return re.sub(
r'\\U([0-9a-fA-F]{8})',
lambda m: chr(int(m.group(1), base=16)), s)
lambda m: chr(int(m.group(1), base=16)), s)

View File

@@ -1,138 +1,29 @@
from youtube import util, html_common, comments
from youtube import yt_app
from youtube import util, comments, local_playlist, yt_data_extract
import settings
from flask import request
import flask
from youtube_dl.YoutubeDL import YoutubeDL
from youtube_dl.extractor.youtube import YoutubeError
import json
import urllib
from string import Template
import html
import gevent
import settings
import os
video_height_priority = (360, 480, 240, 720, 1080)
_formats = {
'5': {'ext': 'flv', 'width': 400, 'height': 240, 'acodec': 'mp3', 'abr': 64, 'vcodec': 'h263'},
'6': {'ext': 'flv', 'width': 450, 'height': 270, 'acodec': 'mp3', 'abr': 64, 'vcodec': 'h263'},
'13': {'ext': '3gp', 'acodec': 'aac', 'vcodec': 'mp4v'},
'17': {'ext': '3gp', 'width': 176, 'height': 144, 'acodec': 'aac', 'abr': 24, 'vcodec': 'mp4v'},
'18': {'ext': 'mp4', 'width': 640, 'height': 360, 'acodec': 'aac', 'abr': 96, 'vcodec': 'h264'},
'22': {'ext': 'mp4', 'width': 1280, 'height': 720, 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264'},
'34': {'ext': 'flv', 'width': 640, 'height': 360, 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264'},
'35': {'ext': 'flv', 'width': 854, 'height': 480, 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264'},
# itag 36 videos are either 320x180 (BaW_jenozKc) or 320x240 (__2ABJjxzNo), abr varies as well
'36': {'ext': '3gp', 'width': 320, 'acodec': 'aac', 'vcodec': 'mp4v'},
'37': {'ext': 'mp4', 'width': 1920, 'height': 1080, 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264'},
'38': {'ext': 'mp4', 'width': 4096, 'height': 3072, 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264'},
'43': {'ext': 'webm', 'width': 640, 'height': 360, 'acodec': 'vorbis', 'abr': 128, 'vcodec': 'vp8'},
'44': {'ext': 'webm', 'width': 854, 'height': 480, 'acodec': 'vorbis', 'abr': 128, 'vcodec': 'vp8'},
'45': {'ext': 'webm', 'width': 1280, 'height': 720, 'acodec': 'vorbis', 'abr': 192, 'vcodec': 'vp8'},
'46': {'ext': 'webm', 'width': 1920, 'height': 1080, 'acodec': 'vorbis', 'abr': 192, 'vcodec': 'vp8'},
'59': {'ext': 'mp4', 'width': 854, 'height': 480, 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264'},
'78': {'ext': 'mp4', 'width': 854, 'height': 480, 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264'},
# 3D videos
'82': {'ext': 'mp4', 'height': 360, 'format_note': '3D', 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264', 'preference': -20},
'83': {'ext': 'mp4', 'height': 480, 'format_note': '3D', 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264', 'preference': -20},
'84': {'ext': 'mp4', 'height': 720, 'format_note': '3D', 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264', 'preference': -20},
'85': {'ext': 'mp4', 'height': 1080, 'format_note': '3D', 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264', 'preference': -20},
'100': {'ext': 'webm', 'height': 360, 'format_note': '3D', 'acodec': 'vorbis', 'abr': 128, 'vcodec': 'vp8', 'preference': -20},
'101': {'ext': 'webm', 'height': 480, 'format_note': '3D', 'acodec': 'vorbis', 'abr': 192, 'vcodec': 'vp8', 'preference': -20},
'102': {'ext': 'webm', 'height': 720, 'format_note': '3D', 'acodec': 'vorbis', 'abr': 192, 'vcodec': 'vp8', 'preference': -20},
# Apple HTTP Live Streaming
'91': {'ext': 'mp4', 'height': 144, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 48, 'vcodec': 'h264', 'preference': -10},
'92': {'ext': 'mp4', 'height': 240, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 48, 'vcodec': 'h264', 'preference': -10},
'93': {'ext': 'mp4', 'height': 360, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264', 'preference': -10},
'94': {'ext': 'mp4', 'height': 480, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264', 'preference': -10},
'95': {'ext': 'mp4', 'height': 720, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 256, 'vcodec': 'h264', 'preference': -10},
'96': {'ext': 'mp4', 'height': 1080, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 256, 'vcodec': 'h264', 'preference': -10},
'132': {'ext': 'mp4', 'height': 240, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 48, 'vcodec': 'h264', 'preference': -10},
'151': {'ext': 'mp4', 'height': 72, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 24, 'vcodec': 'h264', 'preference': -10},
# DASH mp4 video
'133': {'ext': 'mp4', 'height': 240, 'format_note': 'DASH video', 'vcodec': 'h264'},
'134': {'ext': 'mp4', 'height': 360, 'format_note': 'DASH video', 'vcodec': 'h264'},
'135': {'ext': 'mp4', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'h264'},
'136': {'ext': 'mp4', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'h264'},
'137': {'ext': 'mp4', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'h264'},
'138': {'ext': 'mp4', 'format_note': 'DASH video', 'vcodec': 'h264'}, # Height can vary (https://github.com/rg3/youtube-dl/issues/4559)
'160': {'ext': 'mp4', 'height': 144, 'format_note': 'DASH video', 'vcodec': 'h264'},
'212': {'ext': 'mp4', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'h264'},
'264': {'ext': 'mp4', 'height': 1440, 'format_note': 'DASH video', 'vcodec': 'h264'},
'298': {'ext': 'mp4', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'h264', 'fps': 60},
'299': {'ext': 'mp4', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'h264', 'fps': 60},
'266': {'ext': 'mp4', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'h264'},
# Dash mp4 audio
'139': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'abr': 48, 'container': 'm4a_dash'},
'140': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'abr': 128, 'container': 'm4a_dash'},
'141': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'abr': 256, 'container': 'm4a_dash'},
'256': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'container': 'm4a_dash'},
'258': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'container': 'm4a_dash'},
'325': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'dtse', 'container': 'm4a_dash'},
'328': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'ec-3', 'container': 'm4a_dash'},
# Dash webm
'167': {'ext': 'webm', 'height': 360, 'width': 640, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
'168': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
'169': {'ext': 'webm', 'height': 720, 'width': 1280, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
'170': {'ext': 'webm', 'height': 1080, 'width': 1920, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
'218': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
'219': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
'278': {'ext': 'webm', 'height': 144, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp9'},
'242': {'ext': 'webm', 'height': 240, 'format_note': 'DASH video', 'vcodec': 'vp9'},
'243': {'ext': 'webm', 'height': 360, 'format_note': 'DASH video', 'vcodec': 'vp9'},
'244': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'vp9'},
'245': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'vp9'},
'246': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'vp9'},
'247': {'ext': 'webm', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'vp9'},
'248': {'ext': 'webm', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'vp9'},
'271': {'ext': 'webm', 'height': 1440, 'format_note': 'DASH video', 'vcodec': 'vp9'},
# itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug)
'272': {'ext': 'webm', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'vp9'},
'302': {'ext': 'webm', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
'303': {'ext': 'webm', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
'308': {'ext': 'webm', 'height': 1440, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
'313': {'ext': 'webm', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'vp9'},
'315': {'ext': 'webm', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
# Dash webm audio
'171': {'ext': 'webm', 'acodec': 'vorbis', 'format_note': 'DASH audio', 'abr': 128},
'172': {'ext': 'webm', 'acodec': 'vorbis', 'format_note': 'DASH audio', 'abr': 256},
# Dash webm audio with opus inside
'249': {'ext': 'webm', 'format_note': 'DASH audio', 'acodec': 'opus', 'abr': 50},
'250': {'ext': 'webm', 'format_note': 'DASH audio', 'acodec': 'opus', 'abr': 70},
'251': {'ext': 'webm', 'format_note': 'DASH audio', 'acodec': 'opus', 'abr': 160},
# RTMP (unnamed)
'_rtmp': {'protocol': 'rtmp'},
}
with open("yt_watch_template.html", "r") as file:
yt_watch_template = Template(file.read())
def get_related_items_html(info):
result = ""
def get_related_items(info):
results = []
for item in info['related_vids']:
if 'list' in item: # playlist:
item = watch_page_related_playlist_info(item)
result += html_common.playlist_item_html(item, html_common.small_playlist_item_template)
result = watch_page_related_playlist_info(item)
else:
item = watch_page_related_video_info(item)
result += html_common.video_item_html(item, html_common.small_video_item_template)
return result
result = watch_page_related_video_info(item)
yt_data_extract.prefix_urls(result)
yt_data_extract.add_extra_html_info(result)
results.append(result)
return results
# json of related items retrieved directly from the watch page has different names for everything
@@ -145,6 +36,8 @@ def watch_page_related_video_info(item):
except KeyError:
result['views'] = ''
result['thumbnail'] = util.get_thumbnail_url(item['id'])
result['item_size'] = 'small'
result['type'] = 'video'
return result
def watch_page_related_playlist_info(item):
@@ -154,53 +47,49 @@ def watch_page_related_playlist_info(item):
'id': item['list'],
'first_video_id': item['video_id'],
'thumbnail': util.get_thumbnail_url(item['video_id']),
'item_size': 'small',
'type': 'playlist',
}
def sort_formats(info):
sorted_formats = info['formats'].copy()
sorted_formats.sort(key=lambda x: util.default_multi_get(_formats, x['format_id'], 'height', default=0))
for index, format in enumerate(sorted_formats):
if util.default_multi_get(_formats, format['format_id'], 'height', default=0) >= 360:
break
sorted_formats = sorted_formats[index:] + sorted_formats[0:index]
sorted_formats = [format for format in info['formats'] if format['acodec'] != 'none' and format['vcodec'] != 'none']
return sorted_formats
def get_video_sources(info):
video_sources = []
for format in info['formats']:
if format['acodec'] != 'none' and format['vcodec'] != 'none':
video_sources.append({
'src': format['url'],
'type': 'video/' + format['ext'],
})
source_tag_template = Template('''
<source src="$src" type="$type">''')
def formats_html(formats):
result = ''
for format in formats:
result += source_tag_template.substitute(
src=format['url'],
type='audio/' + format['ext'] if format['vcodec'] == "none" else 'video/' + format['ext'],
)
return result
return video_sources
subtitles_tag_template = Template('''
<track label="$label" src="$src" kind="subtitles" srclang="$srclang" $default>''')
def subtitles_html(info):
result = ''
def get_subtitle_sources(info):
sources = []
default_found = False
default = ''
default = None
for language, formats in info['subtitles'].items():
for format in formats:
if format['ext'] == 'vtt':
append = subtitles_tag_template.substitute(
src = html.escape('/' + format['url']),
label = html.escape(language),
srclang = html.escape(language),
default = 'default' if language == settings.subtitles_language and settings.subtitles_mode > 0 else '',
)
source = {
'url': '/' + format['url'],
'label': language,
'srclang': language,
# set as on by default if this is the preferred language and a default-on subtitles mode is in settings
'on': language == settings.subtitles_language and settings.subtitles_mode > 0,
}
if language == settings.subtitles_language:
default_found = True
default = append
default = source
else:
result += append
sources.append(source)
break
result += default
# Put it at the end to avoid browser bug when there are too many languages
# (in firefox, it is impossible to select a language near the top of the list because it is cut off)
if default_found:
sources.append(default)
try:
formats = info['automatic_captions'][settings.subtitles_language]
except KeyError:
@@ -208,19 +97,34 @@ def subtitles_html(info):
else:
for format in formats:
if format['ext'] == 'vtt':
result += subtitles_tag_template.substitute(
src = html.escape('/' + format['url']),
label = settings.subtitles_language + ' - Automatic',
srclang = settings.subtitles_language,
default = 'default' if settings.subtitles_mode == 2 and not default_found else '',
)
return result
sources.append({
'url': '/' + format['url'],
'label': settings.subtitles_language + ' - Automatic',
'srclang': settings.subtitles_language,
# set as on by default if this is the preferred language and a default-on subtitles mode is in settings
'on': settings.subtitles_mode == 2 and not default_found,
})
return sources
more_comments_template = Template('''<a class="page-button more-comments" href="$url">More comments</a>''')
def get_ordered_music_list_attributes(music_list):
# get the set of attributes which are used by atleast 1 track
# so there isn't an empty, extraneous album column which no tracks use, for example
used_attributes = set()
for track in music_list:
used_attributes = used_attributes | track.keys()
# now put them in the right order
ordered_attributes = []
for attribute in ('Artist', 'Title', 'Album'):
if attribute.lower() in used_attributes:
ordered_attributes.append(attribute)
return ordered_attributes
download_link_template = Template('''
<a href="$url"> <span>$ext</span> <span>$resolution</span> <span>$note</span></a>''')
def extract_info(downloader, *args, **kwargs):
try:
@@ -228,136 +132,96 @@ def extract_info(downloader, *args, **kwargs):
except YoutubeError as e:
return str(e)
music_list_table_row = Template('''<tr>
<td>$attribute</td>
<td>$value</td>
''')
def get_watch_page(env, start_response):
video_id = env['parameters']['v'][0]
if len(video_id) < 11:
start_response('404 Not Found', [('Content-type', 'text/plain'),])
return b'Incomplete video id (too short): ' + video_id.encode('ascii')
start_response('200 OK', [('Content-type','text/html'),])
lc = util.default_multi_get(env['parameters'], 'lc', 0, default='')
if settings.route_tor:
proxy = 'socks5://127.0.0.1:9150/'
else:
proxy = ''
downloader = YoutubeDL(params={'youtube_include_dash_manifest':False, 'proxy':proxy})
tasks = (
gevent.spawn(comments.video_comments, video_id, int(settings.default_comment_sorting), lc=lc ),
gevent.spawn(extract_info, downloader, "https://www.youtube.com/watch?v=" + video_id, download=False)
)
gevent.joinall(tasks)
comments_html, info = tasks[0].value, tasks[1].value
#comments_html = comments.comments_html(video_id(url))
#info = YoutubeDL().extract_info(url, download=False)
#chosen_format = choose_format(info)
if isinstance(info, str): # youtube error
return html_common.yt_basic_template.substitute(
page_title = "Error",
style = "",
header = html_common.get_header(),
page = html.escape(info),
).encode('utf-8')
sorted_formats = sort_formats(info)
video_info = {
"duration": util.seconds_to_timestamp(info["duration"]),
"id": info['id'],
"title": info['title'],
"author": info['uploader'],
}
@yt_app.route('/watch')
def get_watch_page():
video_id = request.args['v']
if len(video_id) < 11:
flask.abort(404)
flask.abort(flask.Response('Incomplete video id (too short): ' + video_id))
upload_year = info["upload_date"][0:4]
upload_month = info["upload_date"][4:6]
upload_day = info["upload_date"][6:8]
upload_date = upload_month + "/" + upload_day + "/" + upload_year
if settings.enable_related_videos:
related_videos_html = get_related_items_html(info)
else:
related_videos_html = ''
lc = request.args.get('lc', '')
if settings.route_tor:
proxy = 'socks5://127.0.0.1:9150/'
else:
proxy = ''
yt_dl_downloader = YoutubeDL(params={'youtube_include_dash_manifest':False, 'proxy':proxy})
tasks = (
gevent.spawn(comments.video_comments, video_id, int(settings.default_comment_sorting), lc=lc ),
gevent.spawn(extract_info, yt_dl_downloader, "https://www.youtube.com/watch?v=" + video_id, download=False)
)
gevent.joinall(tasks)
comments_info, info = tasks[0].value, tasks[1].value
music_list = info['music_list']
if len(music_list) == 0:
music_list_html = ''
else:
# get the set of attributes which are used by atleast 1 track
# so there isn't an empty, extraneous album column which no tracks use, for example
used_attributes = set()
for track in music_list:
used_attributes = used_attributes | track.keys()
if isinstance(info, str): # youtube error
return flask.render_template('error.html', error_message = info)
# now put them in the right order
ordered_attributes = []
for attribute in ('Artist', 'Title', 'Album'):
if attribute.lower() in used_attributes:
ordered_attributes.append(attribute)
video_info = {
"duration": util.seconds_to_timestamp(info["duration"]),
"id": info['id'],
"title": info['title'],
"author": info['uploader'],
}
music_list_html = '''<hr>
<table>
<caption>Music</caption>
<tr>
'''
# table headings
for attribute in ordered_attributes:
music_list_html += "<th>" + attribute + "</th>\n"
music_list_html += '''</tr>\n'''
for track in music_list:
music_list_html += '''<tr>\n'''
for attribute in ordered_attributes:
try:
value = track[attribute.lower()]
except KeyError:
music_list_html += '''<td></td>'''
else:
music_list_html += '''<td>''' + html.escape(value) + '''</td>'''
music_list_html += '''</tr>\n'''
music_list_html += '''</table>\n'''
if settings.gather_googlevideo_domains:
with open(os.path.join(settings.data_dir, 'googlevideo-domains.txt'), 'a+', encoding='utf-8') as f:
url = info['formats'][0]['url']
subdomain = url[0:url.find(".googlevideo.com")]
f.write(subdomain + "\n")
download_options = ''
for format in info['formats']:
download_options += download_link_template.substitute(
url = html.escape(format['url']),
ext = html.escape(format['ext']),
resolution = html.escape(downloader.format_resolution(format)),
note = html.escape(downloader._format_note(format)),
)
upload_year = info["upload_date"][0:4]
upload_month = info["upload_date"][4:6]
upload_day = info["upload_date"][6:8]
upload_date = upload_month + "/" + upload_day + "/" + upload_year
if settings.related_videos_mode:
related_videos = get_related_items(info)
else:
related_videos = []
if settings.gather_googlevideo_domains:
with open(os.path.join(settings.data_dir, 'googlevideo-domains.txt'), 'a+', encoding='utf-8') as f:
url = info['formats'][0]['url']
subdomain = url[0:url.find(".googlevideo.com")]
f.write(subdomain + "\n")
download_formats = []
for format in info['formats']:
download_formats.append({
'url': format['url'],
'ext': format['ext'],
'resolution': yt_dl_downloader.format_resolution(format),
'note': yt_dl_downloader._format_note(format),
})
return flask.render_template('watch.html',
header_playlist_names = local_playlist.get_playlist_names(),
uploader_channel_url = '/' + info['uploader_url'],
upload_date = upload_date,
views = (lambda x: '{:,}'.format(x) if x is not None else "")(info.get("view_count", None)),
likes = (lambda x: '{:,}'.format(x) if x is not None else "")(info.get("like_count", None)),
dislikes = (lambda x: '{:,}'.format(x) if x is not None else "")(info.get("dislike_count", None)),
download_formats = download_formats,
video_info = json.dumps(video_info),
video_sources = get_video_sources(info),
subtitle_sources = get_subtitle_sources(info),
related = related_videos,
music_list = info['music_list'],
music_attributes = get_ordered_music_list_attributes(info['music_list']),
comments_info = comments_info,
title = info['title'],
uploader = info['uploader'],
description = info['description'],
unlisted = info['unlisted'],
)
@yt_app.route('/api/<path:dummy>')
def get_captions(dummy):
result = util.fetch_url('https://www.youtube.com' + request.full_path)
result = result.replace(b"align:start position:0%", b"")
return result
page = yt_watch_template.substitute(
video_title = html.escape(info["title"]),
page_title = html.escape(info["title"]),
header = html_common.get_header(),
uploader = html.escape(info["uploader"]),
uploader_channel_url = '/' + info["uploader_url"],
upload_date = upload_date,
views = (lambda x: '{:,}'.format(x) if x is not None else "")(info.get("view_count", None)),
likes = (lambda x: '{:,}'.format(x) if x is not None else "")(info.get("like_count", None)),
dislikes = (lambda x: '{:,}'.format(x) if x is not None else "")(info.get("dislike_count", None)),
download_options = download_options,
video_info = html.escape(json.dumps(video_info)),
description = html.escape(info["description"]),
video_sources = formats_html(sorted_formats) + subtitles_html(info),
related = related_videos_html,
comments = comments_html,
music_list = music_list_html,
is_unlisted = '<span class="is-unlisted">Unlisted</span>' if info['unlisted'] else '',
)
return page.encode('utf-8')

View File

@@ -1,160 +0,0 @@
import mimetypes
import urllib.parse
import os
import re
from youtube import local_playlist, watch, search, playlist, channel, comments, post_comment, accounts, util, subscriptions
import settings
YOUTUBE_FILES = (
"/shared.css",
'/comments.css',
'/favicon.ico',
)
get_handlers = {
'search': search.get_search_page,
'': search.get_search_page,
'watch': watch.get_watch_page,
'playlist': playlist.get_playlist_page,
'channel': channel.get_channel_page,
'user': channel.get_channel_page_general_url,
'c': channel.get_channel_page_general_url,
'playlists': local_playlist.get_playlist_page,
'comments': comments.get_comments_page,
'post_comment': post_comment.get_post_comment_page,
'delete_comment': post_comment.get_delete_comment_page,
'login': accounts.get_account_login_page,
'subscriptions': subscriptions.get_subscriptions_page,
'subscription_manager': subscriptions.get_subscription_manager_page,
}
post_handlers = {
'edit_playlist': local_playlist.edit_playlist,
'playlists': local_playlist.path_edit_playlist,
'login': accounts.add_account,
'comments': post_comment.post_comment,
'post_comment': post_comment.post_comment,
'delete_comment': post_comment.delete_comment,
'subscriptions': subscriptions.post_subscriptions_page,
'subscription_manager': subscriptions.post_subscription_manager_page,
'import_subscriptions': subscriptions.import_subscriptions,
}
def youtube(env, start_response):
path, method, query_string = env['PATH_INFO'], env['REQUEST_METHOD'], env['QUERY_STRING']
env['qs_parameters'] = urllib.parse.parse_qs(query_string)
env['parameters'] = dict(env['qs_parameters'])
path_parts = path.rstrip('/').lstrip('/').split('/')
env['path_parts'] = path_parts
if method == "GET":
try:
handler = get_handlers[path_parts[0]]
except KeyError:
pass
else:
return handler(env, start_response)
if path in YOUTUBE_FILES:
with open("youtube" + path, 'rb') as f:
mime_type = mimetypes.guess_type(path)[0] or 'application/octet-stream'
start_response('200 OK', (('Content-type',mime_type),) )
return f.read()
elif path.startswith('/data/playlist_thumbnails/') or path.startswith('/data/subscription_thumbnails/'):
with open(os.path.join(settings.data_dir, os.path.normpath(path[6:])), 'rb') as f:
start_response('200 OK', (('Content-type', "image/jpeg"),) )
return f.read()
elif path.startswith("/api/"):
start_response('200 OK', [('Content-type', 'text/vtt'),] )
result = util.fetch_url('https://www.youtube.com' + path + ('?' + query_string if query_string else ''))
result = result.replace(b"align:start position:0%", b"")
return result
elif path == "/opensearch.xml":
with open("youtube" + path, 'rb') as f:
mime_type = mimetypes.guess_type(path)[0] or 'application/octet-stream'
start_response('200 OK', (('Content-type',mime_type),) )
return f.read().replace(b'$port_number', str(settings.port_number).encode())
elif path == "/comment_delete_success":
start_response('200 OK', [('Content-type', 'text/plain'),] )
return b'Successfully deleted comment'
elif path == "/comment_delete_fail":
start_response('200 OK', [('Content-type', 'text/plain'),] )
return b'Failed to deleted comment'
else:
return channel.get_channel_page_general_url(env, start_response)
elif method == "POST":
content_type = env['CONTENT_TYPE']
if content_type == 'application/x-www-form-urlencoded':
post_parameters = urllib.parse.parse_qs(env['wsgi.input'].read().decode())
env['post_parameters'] = post_parameters
env['parameters'].update(post_parameters)
# Ugly hack that will be removed once I clean up this trainwreck and switch to a microframework
# Only supports a single file with no other fields
elif content_type.startswith('multipart/form-data'):
content = env['wsgi.input'].read()
# find double line break
file_start = content.find(b'\r\n\r\n')
if file_start == -1:
start_response('400 Bad Request', ())
return b'400 Bad Request'
file_start += 4
lines = content[0:file_start].splitlines()
boundary = lines[0]
file_end = content.find(boundary, file_start)
if file_end == -1:
start_response('400 Bad Request', ())
return b'400 Bad Request'
file_end -= 2 # Subtract newlines
file = content[file_start:file_end]
properties = dict()
for line in lines[1:]:
line = line.decode('utf-8')
colon = line.find(':')
if colon == -1:
continue
properties[line[0:colon]] = line[colon+2:]
mime_type = properties['Content-Type']
field_name = re.search(r'name="([^"]*)"' , properties['Content-Disposition'])
if field_name is None:
start_response('400 Bad Request', ())
return b'400 Bad Request'
field_name = field_name.group(1)
env['post_parameters'] = {field_name: (mime_type, file)}
env['parameters'][field_name] = (mime_type, file)
else:
start_response('400 Bad Request', ())
return b'400 Bad Request'
try:
handler = post_handlers[path_parts[0]]
except KeyError:
pass
else:
return handler(env, start_response)
start_response('404 Not Found', [('Content-type', 'text/plain'),])
return b'404 Not Found'
else:
start_response('501 Not Implemented', [('Content-type', 'text/plain'),])
return b'501 Not Implemented'

View File

@@ -1,4 +1,7 @@
from youtube import util
import html
import json
# videos (all of type str):
@@ -33,19 +36,11 @@ import html
def get_plain_text(node):
try:
return html.escape(node['simpleText'])
return node['simpleText']
except KeyError:
return unformmated_text_runs(node['runs'])
def unformmated_text_runs(runs):
result = ''
for text_run in runs:
result += html.escape(text_run["text"])
return result
return ''.join(text_run['text'] for text_run in node['runs'])
def format_text_runs(runs):
if isinstance(runs, str):
@@ -75,14 +70,19 @@ def get_url(node):
def get_text(node):
if node == {}:
return ''
try:
return node['simpleText']
except KeyError:
pass
pass
try:
return node['runs'][0]['text']
except IndexError: # empty text runs
return ''
except KeyError:
print(node)
raise
def get_formatted_text(node):
try:
@@ -138,9 +138,85 @@ dispatch = {
}
def renderer_info(renderer):
def ajax_info(item_json):
try:
info = {}
for key, node in item_json.items():
try:
simple_key, function = dispatch[key]
except KeyError:
continue
info[simple_key] = function(node)
return info
except KeyError:
print(item_json)
raise
def prefix_urls(item):
try:
item['thumbnail'] = '/' + item['thumbnail'].lstrip('/')
except KeyError:
pass
try:
item['author_url'] = util.URL_ORIGIN + item['author_url']
except KeyError:
pass
def add_extra_html_info(item):
if item['type'] == 'video':
item['url'] = util.URL_ORIGIN + '/watch?v=' + item['id']
video_info = {}
for key in ('id', 'title', 'author', 'duration'):
try:
video_info[key] = item[key]
except KeyError:
video_info[key] = ''
item['video_info'] = json.dumps(video_info)
elif item['type'] == 'playlist':
item['url'] = util.URL_ORIGIN + '/playlist?list=' + item['id']
elif item['type'] == 'channel':
item['url'] = util.URL_ORIGIN + "/channel/" + item['id']
def renderer_info(renderer, additional_info={}):
type = list(renderer.keys())[0]
renderer = renderer[type]
info = {}
if type == 'itemSectionRenderer':
return renderer_info(renderer['contents'][0], additional_info)
if type in ('movieRenderer', 'clarificationRenderer'):
info['type'] = 'unsupported'
return info
info.update(additional_info)
if type.startswith('compact') or (type.startswith('playlist') and type != 'playlistRenderer'):
info['item_size'] = 'small'
else:
info['item_size'] = 'medium'
if type in ('compactVideoRenderer', 'videoRenderer', 'playlistVideoRenderer', 'gridVideoRenderer'):
info['type'] = 'video'
elif type in ('playlistRenderer', 'compactPlaylistRenderer', 'gridPlaylistRenderer',
'radioRenderer', 'compactRadioRenderer', 'gridRadioRenderer',
'showRenderer', 'compactShowRenderer', 'gridShowRenderer'):
info['type'] = 'playlist'
elif type == 'channelRenderer':
info['type'] = 'channel'
elif type == 'playlistHeaderRenderer':
info['type'] = 'playlist_metadata'
else:
info['type'] = 'unsupported'
return info
try:
info = {}
if 'viewCountText' in renderer: # prefer this one as it contains all the digits
info['views'] = get_text(renderer['viewCountText'])
elif 'shortViewCountText' in renderer:
@@ -183,23 +259,20 @@ def renderer_info(renderer):
except KeyError:
continue
info[simple_key] = function(node)
if info['type'] == 'video' and 'duration' not in info:
info['duration'] = 'Live'
return info
except KeyError:
print(renderer)
raise
def ajax_info(item_json):
try:
info = {}
for key, node in item_json.items():
try:
simple_key, function = dispatch[key]
except KeyError:
continue
info[simple_key] = function(node)
return info
except KeyError:
print(item_json)
raise
def parse_info_prepare_for_html(renderer, additional_info={}):
item = renderer_info(renderer, additional_info)
prefix_urls(item)
add_extra_html_info(item)
return item