aboutsummaryrefslogtreecommitdiffstats
path: root/youtube
diff options
context:
space:
mode:
Diffstat (limited to 'youtube')
-rw-r--r--youtube/__init__.py2
-rw-r--r--youtube/html_common.py109
-rw-r--r--youtube/playlist.py58
-rw-r--r--youtube/search.py123
-rw-r--r--youtube/static/comments.css (renamed from youtube/comments.css)0
-rw-r--r--youtube/static/favicon.ico (renamed from youtube/favicon.ico)bin5694 -> 5694 bytes
-rw-r--r--youtube/static/shared.css (renamed from youtube/shared.css)6
-rw-r--r--youtube/templates/base.html113
-rw-r--r--youtube/templates/common_elements.html152
-rw-r--r--youtube/templates/error.html8
-rw-r--r--youtube/templates/playlist.html106
-rw-r--r--youtube/templates/search.html54
-rw-r--r--youtube/templates/watch.html209
-rw-r--r--youtube/watch.py478
-rw-r--r--youtube/youtube.py105
-rw-r--r--youtube/yt_data_extract.py115
16 files changed, 1007 insertions, 631 deletions
diff --git a/youtube/__init__.py b/youtube/__init__.py
new file mode 100644
index 0000000..0df56d1
--- /dev/null
+++ b/youtube/__init__.py
@@ -0,0 +1,2 @@
+import flask
+yt_app = flask.Flask(__name__) \ No newline at end of file
diff --git a/youtube/html_common.py b/youtube/html_common.py
index 8e65a1f..b8ea0d6 100644
--- a/youtube/html_common.py
+++ b/youtube/html_common.py
@@ -104,115 +104,6 @@ medium_channel_item_template = Template('''
-
-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))
diff --git a/youtube/playlist.py b/youtube/playlist.py
index fbe6448..18ddf49 100644
--- a/youtube/playlist.py
+++ b/youtube/playlist.py
@@ -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
@@ -76,14 +75,15 @@ def get_videos(playlist_id, page):
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 +98,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'])
+ yt_data_extract.prefix_urls(metadata)
- metadata = yt_data_extract.renderer_info(first_page_json['response']['header']['playlistHeaderRenderer'])
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'])
-
- html_ready = html_common.get_html_ready(metadata)
- html_ready['page_title'] = html_ready['title'] + ' - Page ' + str(page)
-
- 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['size'] += ' videos'
+
+ return flask.render_template('playlist.html',
+ video_list = parsed_video_list,
+ num_pages = math.ceil(video_count/20),
+ parameters_dictionary = request.args,
+
+ **metadata
).encode('utf-8')
diff --git a/youtube/search.py b/youtube/search.py
index 0cef0f3..76a814c 100644
--- a/youtube/search.py
+++ b/youtube/search.py
@@ -1,16 +1,12 @@
-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 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()
-
+from flask import request
+import flask
# Sort: 1
# Upload date: 2
@@ -58,85 +54,74 @@ def get_search_json(query, page, autocorrect, sort, filters):
content = util.fetch_url(url, headers=headers, report_text="Got 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,
+ )
+
+
diff --git a/youtube/comments.css b/youtube/static/comments.css
index 4cec3e1..4cec3e1 100644
--- a/youtube/comments.css
+++ b/youtube/static/comments.css
diff --git a/youtube/favicon.ico b/youtube/static/favicon.ico
index 9d6417c..9d6417c 100644
--- a/youtube/favicon.ico
+++ b/youtube/static/favicon.ico
Binary files differ
diff --git a/youtube/shared.css b/youtube/static/shared.css
index 1b25d7f..a360972 100644
--- a/youtube/shared.css
+++ b/youtube/static/shared.css
@@ -219,6 +219,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;
diff --git a/youtube/templates/base.html b/youtube/templates/base.html
new file mode 100644
index 0000000..eafd369
--- /dev/null
+++ b/youtube/templates/base.html
@@ -0,0 +1,113 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>{% block page_title %}{{ title }}{% endblock %}</title>
+ <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>
diff --git a/youtube/templates/common_elements.html b/youtube/templates/common_elements.html
new file mode 100644
index 0000000..b140332
--- /dev/null
+++ b/youtube/templates/common_elements.html
@@ -0,0 +1,152 @@
+{% 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) %}
+ <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) %}
+ {% 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 %}
+ {% 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) %}
+ <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) }}
+ </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) }}
+ </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'] }}</span>
+ <span>{{ info['size'] }}</span>
+
+ <span class="description">{{ text_runs(info['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) %}
+ {% if info['item_size'] == 'small' %}
+ {{ small_item(info) }}
+ {% elif info['item_size'] == 'medium' %}
+ {{ medium_item(info) }}
+ {% 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 %}
diff --git a/youtube/templates/error.html b/youtube/templates/error.html
new file mode 100644
index 0000000..1f33c44
--- /dev/null
+++ b/youtube/templates/error.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+
+{% block page_title %}Error{% endblock %}
+
+{% block main %}
+ {{ error_message }}
+{% endblock %}
+
diff --git a/youtube/templates/playlist.html b/youtube/templates/playlist.html
new file mode 100644
index 0000000..09e382b
--- /dev/null
+++ b/youtube/templates/playlist.html
@@ -0,0 +1,106 @@
+{% extends "base.html" %}
+{% block page_title %}{{ title + ' - Page ' + parameters_dictionary.get('page', '1') }}{% endblock %}
+{% 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">{{ 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 %}
+
+
+
+
+
+
diff --git a/youtube/templates/search.html b/youtube/templates/search.html
new file mode 100644
index 0000000..1086cfd
--- /dev/null
+++ b/youtube/templates/search.html
@@ -0,0 +1,54 @@
+{% set search_box_value = query %}
+{% extends "base.html" %}
+{% block page_title %}{{ query + ' - Search' }}{% endblock %}
+{% 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 %}
diff --git a/youtube/templates/watch.html b/youtube/templates/watch.html
new file mode 100644
index 0000000..122958c
--- /dev/null
+++ b/youtube/templates/watch.html
@@ -0,0 +1,209 @@
+{% extends "base.html" %}
+{% import "common_elements.html" as common_elements %}
+{% block page_title %}{{ title }}{% endblock %}
+{% 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">
+{{ music_list|safe }}
+ </div>
+{{ comments|safe }}
+ </article>
+
+
+
+
+ <nav id="related">
+ {% for info in related %}
+ {{ common_elements.item(info) }}
+ {% endfor %}
+ </nav>
+
+{% endblock main %}
diff --git a/youtube/watch.py b/youtube/watch.py
index 06b525a..1f7a352 100644
--- a/youtube/watch.py
+++ b/youtube/watch.py
@@ -1,138 +1,29 @@
-from youtube import util, html_common, comments
+from youtube import yt_app
+from youtube import util, html_common, 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
-
-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
+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'],
+ })
+ 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,60 @@ 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
+
+
+def get_music_list_html(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()
+
+ # 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)
+
+ 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'''
+ return music_list_html
-more_comments_template = Template('''<a class="page-button more-comments" href="$url">More comments</a>''')
-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 +158,86 @@ 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'],
- }
-
- 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 = ''
- 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()
-
- # 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)
-
- 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)),
- )
-
-
- 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')
+
+@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))
+
+ 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_html, info = tasks[0].value, tasks[1].value
+
+ if isinstance(info, str): # youtube error
+ return flask.render_template('error.html', header = html_common.get_header, error_mesage = info)
+
+ video_info = {
+ "duration": util.seconds_to_timestamp(info["duration"]),
+ "id": info['id'],
+ "title": info['title'],
+ "author": info['uploader'],
+ }
+
+ 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 = 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,
+
+ # TODO: refactor these
+ comments = comments_html,
+ music_list = get_music_list_html(info['music_list']),
+
+ title = info['title'],
+ uploader = info['uploader'],
+ description = info['description'],
+ unlisted = info['unlisted'],
+ )
diff --git a/youtube/youtube.py b/youtube/youtube.py
deleted file mode 100644
index a6a216e..0000000
--- a/youtube/youtube.py
+++ /dev/null
@@ -1,105 +0,0 @@
-import mimetypes
-import urllib.parse
-import os
-from youtube import local_playlist, watch, search, playlist, channel, comments, post_comment, accounts, util
-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,
-}
-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,
-}
-
-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/"):
- 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":
- post_parameters = urllib.parse.parse_qs(env['wsgi.input'].read().decode())
- env['post_parameters'] = post_parameters
- env['parameters'].update(post_parameters)
-
- 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'
diff --git a/youtube/yt_data_extract.py b/youtube/yt_data_extract.py
index 5483911..a42b6a2 100644
--- a/youtube/yt_data_extract.py
+++ b/youtube/yt_data_extract.py
@@ -1,4 +1,7 @@
+from youtube import util
+
import html
+import json
# videos (all of type str):
@@ -138,9 +141,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') or type.startswith('grid'):
+ 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 +262,25 @@ 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
-
+
+
+
+ #print(renderer)
+ #raise NotImplementedError('Unknown renderer type: ' + type)
+ return ''
+
+def parse_info_prepare_for_html(renderer):
+ item = renderer_info(renderer)
+ prefix_urls(item)
+ add_extra_html_info(item)
+
+ return item
+