checkpoint

This commit is contained in:
voussoir 2016-11-28 20:16:16 -08:00
commit bd263b2ed7
17 changed files with 1223 additions and 0 deletions

4
README.md Normal file
View file

@ -0,0 +1,4 @@
Youtube Channel Downloader
==========================
You are responsible for your own `bot.py` file, containing a variable `YOUTUBE_KEY`.

31
helpers.py Normal file
View file

@ -0,0 +1,31 @@
def chunk_sequence(sequence, chunk_length, allow_incomplete=True):
'''
Given a sequence, divide it into sequences of length `chunk_length`.
:param allow_incomplete: If True, allow the final chunk to be shorter if the
given sequence is not an exact multiple of `chunk_length`.
If False, the incomplete chunk will be discarded.
'''
(complete, leftover) = divmod(len(sequence), chunk_length)
if not allow_incomplete:
leftover = 0
chunk_count = complete + min(leftover, 1)
chunks = []
for x in range(chunk_count):
left = chunk_length * x
right = left + chunk_length
chunks.append(sequence[left:right])
return chunks
def truthystring(s):
if isinstance(s, (bool, int)) or s is None:
return s
s = s.lower()
if s in {'1', 'true', 't', 'yes', 'y', 'on'}:
return True
if s in {'null', 'none'}:
return None
return False

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
flask
gevent
https://github.com/voussoir/else/raw/master/_voussoirkit/voussoirkit.zip

32
static/common.css Normal file
View file

@ -0,0 +1,32 @@
body
{
display: flex;
flex-direction: column;
background-color:#fff;
margin: 8px;
}
#header
{
display: flex;
flex-direction: row;
justify-content: center;
align-content: center;
margin-bottom: 4px;
}
.header_element
{
display: flex;
justify-content: center;
flex: 1;
background-color: rgba(0, 0, 0, 0.1);
}
.header_element:hover
{
background-color: rgba(0, 0, 0, 0.2);
}
#content_body
{
flex: 0 0 auto;
display: flex;
flex-direction: row;
}

83
static/common.js Normal file
View file

@ -0,0 +1,83 @@
function post_example(key, value, callback)
{
var url = "/postexample";
data = new FormData();
data.append(key, value);
return post(url, data, callback);
}
function null_callback()
{
return;
}
function post(url, data, callback)
{
var request = new XMLHttpRequest();
request.answer = null;
request.onreadystatechange = function()
{
if (request.readyState == 4)
{
var text = request.responseText;
if (callback != null)
{
console.log(text);
callback(JSON.parse(text));
}
}
};
var asynchronous = true;
request.open("POST", url, asynchronous);
request.send(data);
}
function bind_box_to_button(box, button)
{
box.onkeydown=function()
{
if (event.keyCode == 13)
{
button.click();
}
};
}
function entry_with_history_hook(box, button)
{
//console.log(event.keyCode);
if (box.entry_history === undefined)
{box.entry_history = [];}
if (box.entry_history_pos === undefined)
{box.entry_history_pos = -1;}
if (event.keyCode == 13)
{
/* Enter */
box.entry_history.push(box.value);
button.click();
box.value = "";
}
else if (event.keyCode == 38)
{
/* Up arrow */
if (box.entry_history.length == 0)
{return}
if (box.entry_history_pos == -1)
{
box.entry_history_pos = box.entry_history.length - 1;
}
else if (box.entry_history_pos > 0)
{
box.entry_history_pos -= 1;
}
box.value = box.entry_history[box.entry_history_pos];
}
else if (event.keyCode == 27)
{
box.value = "";
}
else
{
box.entry_history_pos = -1;
}
}

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

138
static/favicon.svg Normal file
View file

@ -0,0 +1,138 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
id="svg2"
viewBox="0 0 319.99997 319.99997"
height="319.99997"
width="319.99997">
<defs
id="defs4">
<clipPath
id="clipPath20"
clipPathUnits="userSpaceOnUse">
<path
id="path18"
d="m 0,0 0,2091 5010,0 0,-2091 z" />
</clipPath>
<clipPath
id="clipPath26"
clipPathUnits="userSpaceOnUse">
<path
id="path24"
d="m 4980.41,1639.73 c 0,0 -29.18,204.85 -118.71,295.06 -113.54,118.39 -240.84,118.99 -299.2,125.91 -417.85,30.07 -1044.65,30.07 -1044.65,30.07 l -1.31,0 c 0,0 -626.81,0 -1044.66,-30.07 -58.38,-6.92 -185.63,-7.52 -299.2,-125.91 -89.54,-90.21 -118.68,-295.06 -118.68,-295.06 0,0 -29.86,-240.55 -29.86,-481.12 l 0,-225.528 c 0,-240.543 29.86,-481.094 29.86,-481.094 0,0 29.14,-204.84 118.68,-295.058 C 2286.25,38.5117 2435.49,42.2813 2501.93,29.8789 2740.82,7.05078 3517.19,0 3517.19,0 c 0,0 627.46,0.941406 1045.31,30.9883 58.36,6.9414 185.66,7.5234 299.2,125.9417 89.53,90.218 118.71,295.058 118.71,295.058 0,0 29.82,240.551 29.82,481.094 l 0,225.528 c 0,240.57 -29.82,481.12 -29.82,481.12 z" />
</clipPath>
<linearGradient
id="linearGradient32"
spreadMethod="pad"
gradientTransform="matrix(-9.14e-5,-2090.78,-2090.78,9.14e-5,3329.69,2262.1986)"
gradientUnits="userSpaceOnUse"
y2="0"
x2="1"
y1="0"
x1="0">
<stop
id="stop28"
offset="0"
style="stop-opacity:1;stop-color:#e42526" />
<stop
id="stop30"
offset="1"
style="stop-opacity:1;stop-color:#be1f26" />
</linearGradient>
<clipPath
id="clipPath20-2"
clipPathUnits="userSpaceOnUse">
<path
id="path18-4"
d="m 0,0 0,2091 5010,0 0,-2091 z" />
</clipPath>
<clipPath
id="clipPath26-0"
clipPathUnits="userSpaceOnUse">
<path
id="path24-4"
d="m 4980.41,1639.73 c 0,0 -29.18,204.85 -118.71,295.06 -113.54,118.39 -240.84,118.99 -299.2,125.91 -417.85,30.07 -1044.65,30.07 -1044.65,30.07 l -1.31,0 c 0,0 -626.81,0 -1044.66,-30.07 -58.38,-6.92 -185.63,-7.52 -299.2,-125.91 -89.54,-90.21 -118.68,-295.06 -118.68,-295.06 0,0 -29.86,-240.55 -29.86,-481.12 l 0,-225.528 c 0,-240.543 29.86,-481.094 29.86,-481.094 0,0 29.14,-204.84 118.68,-295.058 C 2286.25,38.5117 2435.49,42.2813 2501.93,29.8789 2740.82,7.05078 3517.19,0 3517.19,0 c 0,0 627.46,0.941406 1045.31,30.9883 58.36,6.9414 185.66,7.5234 299.2,125.9417 89.53,90.218 118.71,295.058 118.71,295.058 0,0 29.82,240.551 29.82,481.094 l 0,225.528 c 0,240.57 -29.82,481.12 -29.82,481.12 z" />
</clipPath>
<linearGradient
id="linearGradient32-7"
spreadMethod="pad"
gradientTransform="matrix(-9.14e-5,-2090.78,-2090.78,9.14e-5,3517.19,2090.77)"
gradientUnits="userSpaceOnUse"
y2="0"
x2="1"
y1="0"
x1="0">
<stop
id="stop28-9"
offset="0"
style="stop-opacity:1;stop-color:#e42526" />
<stop
id="stop30-8"
offset="1"
style="stop-opacity:1;stop-color:#be1f26" />
</linearGradient>
<linearGradient
gradientTransform="matrix(3.235294,0,0,3.235294,718.68591,-1718.4362)"
gradientUnits="userSpaceOnUse"
y2="738.38818"
x2="-89.038963"
y1="654.93365"
x1="-134.07793"
id="linearGradient4244"
xlink:href="#linearGradient32" />
</defs>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
transform="translate(-214.90438,-363.32393)"
id="layer1">
<g
transform="matrix(1.3333333,0,0,-1.3333333,-114.068,677.6876)"
id="g10">
<g
transform="scale(0.1,0.1)"
id="g12">
<g
id="g14">
<g
clip-path="url(#clipPath20)"
id="g16">
<g
clip-path="url(#clipPath26)"
id="g22" />
</g>
</g>
</g>
</g>
<g
transform="matrix(1.0181818,0,0,1.0181818,-3.9073586,-12.424091)"
id="g4256">
<circle
style="opacity:1;fill:url(#linearGradient4244);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path4236"
cx="372.04724"
cy="526.18109"
r="157.14285" />
<path
id="path4254"
d="m 341.68884,417.25239 0,129.3164 -38.57031,0 68.92774,88.54102 68.92968,-88.54102 -38.57226,0 0,-129.3164 -60.71485,0 z"
style="opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

224
templates/channel.html Normal file
View file

@ -0,0 +1,224 @@
<!DOCTYPE html5>
<html>
<head>
{% import "header.html" as header %}
<title>{{channel['name']}}</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="/static/common.css">
<script src="/static/common.js"></script>
<style>
#content_body
{
display: flex;
flex-direction: column;
}
.video_card_downloaded,
.video_card_ignored,
.video_card_pending
{
position: relative;
margin: 8px;
padding: 10px;
border-radius: 4px;
border: 1px solid #000;
}
.video_card_pending
{
background-color: #ffffaa;
}
.video_card_ignored
{
background-color: #ffc886;
}
.video_card_downloaded
{
background-color: #aaffaa;
}
.action_toolbox
{
float: right;
display: inline-flex;
flex-direction: row;
position: relative;
}
.video_action_dropdown
{
z-index: 1;
background-color: #fff;
padding: 4px;
border: 1px solid #000;
position: absolute;
top: 100%;
right: 0;
display: none;
flex-direction: column;
}
.refresh_button
{
width: 10%;
}
</style>
</head>
<body>
{{header.make_header()}}
<div id="content_body">
<button class="refresh_button" onclick="refresh_channel('{{channel['id']}}', false, function(){location.reload()})">Refresh new videos</button>
<button class="refresh_button" onclick="refresh_channel('{{channel['id']}}', true, function(){location.reload()})">Refresh everything</button>
<span><a href="/channel/{{channel['id']}}">All</a></span>
<span><a href="/channel/{{channel['id']}}/pending">Pending</a></span>
<span><a href="/channel/{{channel['id']}}/ignored">Ignored</a></span>
<span><a href="/channel/{{channel['id']}}/downloaded">Downloaded</a></span>
<span>{{videos|length}} items</span>
{% for video in videos %}
<div id="video_card_{{video['id']}}"
{% if video['download'] == "downloaded" %}
class="video_card video_card_downloaded"
{% elif video['download'] == "ignored" %}
class="video_card video_card_ignored"
{% else %}
class="video_card video_card_pending"
{% endif %}
>
<a href="https://www.youtube.com/watch?v={{video['id']}}">{{video['_published_str']}} - {{video['title']}}</a>
<div class="action_toolbox">
{% if video['download'] == "downloaded" %}
<button
class="video_action_pending"
onclick="mark_video_state('{{video['id']}}', 'pending', receive_action_response);"
>Revert to Pending</button>
{% elif video['download'] == "ignored" %}
<button
class="video_action_pending"
onclick="mark_video_state('{{video['id']}}', 'pending', receive_action_response);"
>Revert to Pending</button>
{% else %}
<button
class="video_action_download"
onclick="start_download('{{video['id']}}', receive_action_response);"
>Download</button>
<button
class="video_action_ignore"
onclick="mark_video_state('{{video['id']}}', 'ignored', receive_action_response);"
>Ignore</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</body>
<script type="text/javascript">
function give_action_buttons(video_card_div)
{
var toolbox = video_card_div.getElementsByClassName("action_toolbox")[0];
var video_id = video_card_div.id.split("video_card_")[1];
while (toolbox.children.length > 0)
{
toolbox.removeChild(toolbox.firstChild);
}
if (video_card_div.classList.contains("video_card_pending"))
{
var button_download = document.createElement("button");
button_download.innerHTML = "Download";
button_download.onclick = function(){
start_download(video_id, receive_action_response);
}
var button_ignore = document.createElement("button");
button_ignore.innerHTML = "Ignore";
button_ignore.onclick = function(){
mark_video_state(video_id, 'ignored', receive_action_response);
}
toolbox.appendChild(button_download);
toolbox.appendChild(button_ignore);
}
else
{
var button_revert = document.createElement("button");
button_revert.innerHTML = "Revert to Pending";
button_revert.onclick = function(){
mark_video_state(video_id, 'pending', receive_action_response);
}
toolbox.appendChild(button_revert);
}
}
var video_cards = document.getElementsByClassName("video_card");
for (var i = 0; i < video_cards.length; i += 1)
{
give_action_buttons(video_cards[i]);
}
function receive_action_response(response)
{
var video_id = response['video_id'];
var state = response['state'];
var card = document.getElementById("video_card_" + video_id);
if (state == 'pending')
{
card.classList = ["video_card", "video_card_pending"].join(" ");
card.style.backgroundColor = "#ffffaa";
}
else if (state == 'ignored')
{
card.classList = ["video_card", "video_card_ignored"].join(" ");
card.style.backgroundColor = "#ffc886";
}
else if (state == 'downloaded')
{
card.classList = ["video_card", "video_card_downloaded"].join(" ");
card.style.backgroundColor = "#aaffaa";
}
give_action_buttons(card);
}
function refresh_channel(channel_id, force, callback)
{
var url = "/refresh_channel";
data = new FormData();
data.append("channel_id", channel_id);
data.append("force", force)
return post(url, data, callback);
}
function mark_video_state(video_id, state, callback)
{
var url = "/mark_video_state";
data = new FormData();
data.append("video_id", video_id);
data.append("state", state);
return post(url, data, callback);
}
function start_download(video_id, callback)
{
var url = "/start_download";
data = new FormData();
data.append("video_id", video_id);
return post(url, data, callback);
}
function toggle_dropdown(dropdown)
{
if (dropdown.style.display != "inline-flex")
{
dropdown.style.display = "inline-flex";
}
else
{
dropdown.style.display = "none";
}
}
</script>
</html>

96
templates/channels.html Normal file
View file

@ -0,0 +1,96 @@
<!DOCTYPE html5>
<html>
<head>
{% import "header.html" as header %}
<title>Channels</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="/static/common.css">
<script src="/static/common.js"></script>
<style>
#content_body
{
display: flex;
flex-direction: column;
}
#new_channel_textbox,
#new_channel_button
{
width: 200px;
}
.channel_card_downloaded,
.channel_card_pending
{
margin: 8px;
padding: 10px;
border-radius: 4px;
border: 1px solid #000;
}
.channel_card_pending
{
background-color: #ffffaa;
}
.channel_card_downloaded
{
background-color: #aaffaa;
}
.refresh_button
{
width: 10%;
}
</style>
</head>
<body>
{{header.make_header()}}
<div id="content_body">
<button class="refresh_button" onclick="refresh_all_channels(false, function(){location.reload()})">Refresh new videos</button>
<button class="refresh_button" onclick="refresh_all_channels(true, function(){location.reload()})">Refresh everything</button>
<div>
<input type="text" id="new_channel_textbox">
<button id="new_channel_button" onclick="_new_channel_submit()">Add new channel</button>
</div>
{% for channel in channels %}
{% if channel['has_pending'] %}
<div class="channel_card_pending">
{% else %}
<div class="channel_card_downloaded">
{% endif %}
<a href="/channel/{{channel['id']}}">{{channel['name']}}</a>
</div>
{% endfor %}
</div>
</body>
<script type="text/javascript">
var box = document.getElementById('new_channel_textbox');
var button = document.getElementById('new_channel_button');
bind_box_to_button(box, button);
function _new_channel_submit()
{
if (box.value !== "")
{
refresh_channel(box.value, false, function(){location.reload()});
}
}
function refresh_channel(channel_id, force, callback)
{
var url = "/refresh_channel";
data = new FormData();
data.append("channel_id", channel_id);
data.append("force", force)
return post(url, data, callback);
}
function refresh_all_channels(force, callback)
{
var url = "/refresh_all_channels";
data = new FormData();
data.append("force", force)
return post(url, data, callback);
}
</script>
</html>

6
templates/header.html Normal file
View file

@ -0,0 +1,6 @@
{% macro make_header() %}
<div id="header">
<a class="header_element" href="/">Home</a>
<a class="header_element" href="/channels">Channels</a>
</div>
{% endmacro %}

28
templates/root.html Normal file
View file

@ -0,0 +1,28 @@
<!DOCTYPE html5>
<html>
<head>
{% import "header.html" as header %}
<title>YCDL</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="/static/common.css">
<style>
body, a
{
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>
</head>
<body>
<a href='/channels'>Manage channels</a>
</body>
<script type="text/javascript">
</script>
</html>

239
ycdl.py Normal file
View file

@ -0,0 +1,239 @@
import logging
import os
import sqlite3
import ytapi
# AVAILABLE FORMATTERS:
# url, id
# Note that if the channel has a value in the `directory` column, the bot will
# chdir there before executing.
YOUTUBE_DL_COMMAND = 'touch C:\\Incoming\\ytqueue\\{id}.ytqueue'
logging.basicConfig(level=logging.DEBUG)
log = logging.getLogger(__name__)
logging.getLogger('googleapiclient.discovery').setLevel(logging.WARNING)
logging.getLogger('requests.packages.urllib3.connectionpool').setLevel(logging.WARNING)
SQL_CHANNEL_COLUMNS = [
'id',
'name',
'directory',
]
SQL_VIDEO_COLUMNS = [
'id',
'published',
'author_id',
'title',
'description',
'thumbnail',
'download',
]
SQL_CHANNEL = {key:index for (index, key) in enumerate(SQL_CHANNEL_COLUMNS)}
SQL_VIDEO = {key:index for (index, key) in enumerate(SQL_VIDEO_COLUMNS)}
DATABASE_VERSION = 1
DB_INIT = '''
PRAGMA count_changes = OFF;
PRAGMA cache_size = 10000;
PRAGMA user_version = {user_version};
CREATE TABLE IF NOT EXISTS channels(
id TEXT,
name TEXT,
directory TEXT COLLATE NOCASE
);
CREATE TABLE IF NOT EXISTS videos(
id TEXT,
published INT,
author_id TEXT,
title TEXT,
description TEXT,
thumbnail TEXT,
download TEXT
);
CREATE INDEX IF NOT EXISTS index_channel_id on channels(id);
CREATE INDEX IF NOT EXISTS index_video_id on videos(id);
CREATE INDEX IF NOT EXISTS index_video_published on videos(published);
CREATE INDEX IF NOT EXISTS index_video_download on videos(download);
'''.format(user_version=DATABASE_VERSION)
DEFAULT_DBNAME = 'ycdl.db'
ERROR_DATABASE_OUTOFDATE = 'Database is out-of-date. {current} should be {new}'
def verify_is_abspath(path):
'''
TO DO: Determine whether this is actually correct.
'''
if os.path.abspath(path) != path:
raise ValueError('Not an abspath')
class YCDL:
def __init__(self, youtube, database_filename=None):
self.youtube = youtube
if database_filename is None:
database_filename = DEFAULT_DBNAME
existing_database = os.path.exists(database_filename)
self.sql = sqlite3.connect(database_filename)
self.cur = self.sql.cursor()
if existing_database:
self.cur.execute('PRAGMA user_version')
existing_version = self.cur.fetchone()[0]
if existing_version != DATABASE_VERSION:
message = ERROR_DATABASE_OUTOFDATE
message = message.format(current=existing_version, new=DATABASE_VERSION)
print(message)
raise SystemExit
statements = DB_INIT.split(';')
for statement in statements:
self.cur.execute(statement)
def add_channel(
self,
channel_id,
commit=False,
download_directory=None,
get_videos=True,
name=None,
):
if self.get_channel(channel_id) is not None:
return
if name is None:
name = self.youtube.get_user_name(channel_id)
data = [None] * len(SQL_CHANNEL)
data[SQL_CHANNEL['id']] = channel_id
data[SQL_CHANNEL['name']] = name
if download_directory is not None:
verify_is_abspath(download_directory)
data[SQL_CHANNEL['directory']] = download_directory
self.cur.execute('INSERT INTO channels VALUES(?, ?, ?)', data)
if get_videos:
self.refresh_channel(channel_id, commit=False)
if commit:
self.sql.commit()
def channel_has_pending(self, channel_id):
query = 'SELECT * FROM videos WHERE author_id == ? AND download == "pending"'
self.cur.execute(query, [channel_id])
return self.cur.fetchone() is not None
def channel_directory(self, channel_id):
self.cur.execute('SELECT * FROM channels WHERE id == ?', [channel_id])
fetch = self.cur.fetchone()
if fetch is None:
return None
return fetch[SQL_CHANNEL['directory']]
def download_video(self, video, force=False):
if not isinstance(video, ytapi.Video):
video = self.youtube.get_video(video)
self.add_channel(video.author_id, get_videos=False, commit=False)
status = self.insert_video(video, commit=True)
if status['row'][SQL_VIDEO['download']] != 'pending' and not force:
print('That video does not need to be downloaded.')
return
download_directory = self.channel_directory(video.author_id)
download_directory = download_directory or os.getcwd()
current_directory = os.getcwd()
os.makedirs(download_directory, exist_ok=True)
os.chdir(download_directory)
url = 'https://www.youtube.com/watch?v={id}'.format(id=video.id)
command = YOUTUBE_DL_COMMAND.format(url=url, id=video.id)
os.system(command)
os.chdir(current_directory)
self.cur.execute('UPDATE videos SET download = "downloaded" WHERE id == ?', [video.id])
self.sql.commit()
def get_channel(self, channel_id):
self.cur.execute('SELECT * FROM channels WHERE id == ?', [channel_id])
fetch = self.cur.fetchone()
if not fetch:
return None
fetch = {key: fetch[SQL_CHANNEL[key]] for key in SQL_CHANNEL}
return fetch
def get_channels(self):
self.cur.execute('SELECT * FROM channels')
channels = self.cur.fetchall()
channels = [{key: channel[SQL_CHANNEL[key]] for key in SQL_CHANNEL} for channel in channels]
channels.sort(key=lambda x: x['name'].lower())
return channels
def get_videos(self, channel_id=None):
if channel_id is not None:
self.cur.execute('SELECT * FROM videos WHERE author_id == ?', [channel_id])
else:
self.cur.execute('SELECT * FROM videos ')
videos = self.cur.fetchall()
if not videos:
return []
videos = [{key: video[SQL_VIDEO[key]] for key in SQL_VIDEO} for video in videos]
videos.sort(key=lambda x: x['published'], reverse=True)
return videos
def insert_video(self, video, commit=True):
if not isinstance(video, ytapi.Video):
video = self.youtube.get_video(video)
self.add_channel(video.author_id, get_videos=False, commit=False)
self.cur.execute('SELECT * FROM videos WHERE id == ?', [video.id])
fetch = self.cur.fetchone()
if fetch is not None:
return {'new': False, 'row': fetch}
data = [None] * len(SQL_VIDEO)
data[SQL_VIDEO['id']] = video.id
data[SQL_VIDEO['published']] = video.published
data[SQL_VIDEO['author_id']] = video.author_id
data[SQL_VIDEO['title']] = video.title
data[SQL_VIDEO['description']] = video.description
data[SQL_VIDEO['thumbnail']] = video.thumbnail['url']
data[SQL_VIDEO['download']] = 'pending'
self.cur.execute('INSERT INTO videos VALUES(?, ?, ?, ?, ?, ?, ?)', data)
if commit:
self.sql.commit()
return {'new': True, 'row': data}
def mark_video_state(self, video_id, state, commit=True):
'''
Mark the video as ignored, pending, or downloaded.
'''
if state not in ['ignored', 'pending', 'downloaded']:
raise ValueError(state)
self.cur.execute('SELECT * FROM videos WHERE id == ?', [video_id])
if self.cur.fetchone() is None:
raise KeyError(video_id)
self.cur.execute('UPDATE videos SET download = ? WHERE id == ?', [state, video_id])
if commit:
self.sql.commit()
def refresh_all_channels(self, force=False, commit=True):
for channel in self.get_channels():
self.refresh_channel(channel['id'], force=force, commit=commit)
if commit:
self.sql.commit()
def refresh_channel(self, channel_id, force=False, commit=True):
video_generator = self.youtube.get_user_videos(uid=channel_id)
log.debug('Refreshing channel: %s', channel_id)
for video in video_generator:
status = self.insert_video(video, commit=False)
if not force and not status['new']:
break
if commit:
self.sql.commit()

11
ycdl_easy.py Normal file
View file

@ -0,0 +1,11 @@
'''
Run `python -i ycdl_easy.py to get an interpreter
session with these variables preloaded.
'''
import bot
import ycdl
import ytapi
youtube_core = ytapi.Youtube(bot.YOUTUBE_KEY)
youtube = ycdl.YCDL(youtube_core)

29
ycdl_launch.py Normal file
View file

@ -0,0 +1,29 @@
import gevent.monkey
gevent.monkey.patch_all()
import gevent.pywsgi
import gevent.wsgi
import sys
import ycdl_site
if len(sys.argv) == 2:
port = int(sys.argv[1])
else:
port = 5000
if port == 443:
http = gevent.pywsgi.WSGIServer(
listener=('', port),
application=ycdl_site.site,
keyfile='https\\flasksite.key',
certfile='https\\flasksite.crt',
)
else:
http = gevent.pywsgi.WSGIServer(
listener=('', port),
application=ycdl_site.site,
)
print('Starting server')
http.serve_forever()

195
ycdl_site.py Normal file
View file

@ -0,0 +1,195 @@
'''
Do not execute this file directly.
Use ycdl_launch.py to start the server with gevent.
'''
import datetime
import flask
from flask import request
import json
import mimetypes
import os
import bot
import helpers
import ycdl
import ytapi
youtube_core = ytapi.Youtube(bot.YOUTUBE_KEY)
youtube = ycdl.YCDL(youtube_core)
site = flask.Flask(__name__)
site.config.update(
SEND_FILE_MAX_AGE_DEFAULT=180,
TEMPLATES_AUTO_RELOAD=True,
)
site.jinja_env.add_extension('jinja2.ext.do')
site.debug = True
####################################################################################################
####################################################################################################
####################################################################################################
####################################################################################################
def make_json_response(j, *args, **kwargs):
dumped = json.dumps(j)
response = flask.Response(dumped, *args, **kwargs)
response.headers['Content-Type'] = 'application/json;charset=utf-8'
return response
def send_file(filepath):
'''
Range-enabled file sending.
'''
try:
file_size = os.path.getsize(filepath)
except FileNotFoundError:
flask.abort(404)
outgoing_headers = {}
mimetype = mimetypes.guess_type(filepath)[0]
if mimetype is not None:
if 'text/' in mimetype:
mimetype += '; charset=utf-8'
outgoing_headers['Content-Type'] = mimetype
if 'range' in request.headers:
desired_range = request.headers['range'].lower()
desired_range = desired_range.split('bytes=')[-1]
int_helper = lambda x: int(x) if x.isdigit() else None
if '-' in desired_range:
(desired_min, desired_max) = desired_range.split('-')
range_min = int_helper(desired_min)
range_max = int_helper(desired_max)
else:
range_min = int_helper(desired_range)
if range_min is None:
range_min = 0
if range_max is None:
range_max = file_size
# because ranges are 0-indexed
range_max = min(range_max, file_size - 1)
range_min = max(range_min, 0)
range_header = 'bytes {min}-{max}/{outof}'.format(
min=range_min,
max=range_max,
outof=file_size,
)
outgoing_headers['Content-Range'] = range_header
status = 206
else:
range_max = file_size - 1
range_min = 0
status = 200
outgoing_headers['Accept-Ranges'] = 'bytes'
outgoing_headers['Content-Length'] = (range_max - range_min) + 1
if request.method == 'HEAD':
outgoing_data = bytes()
else:
outgoing_data = helpers.read_filebytes(filepath, range_min=range_min, range_max=range_max)
response = flask.Response(
outgoing_data,
status=status,
headers=outgoing_headers,
)
return response
####################################################################################################
####################################################################################################
####################################################################################################
####################################################################################################
@site.route('/')
def root():
return flask.render_template('root.html')
@site.route('/favicon.ico')
@site.route('/favicon.png')
def favicon():
filename = os.path.join('static', 'favicon.png')
return flask.send_file(filename)
@site.route('/channels')
def get_channels():
channels = youtube.get_channels()
for channel in channels:
channel['has_pending'] = youtube.channel_has_pending(channel['id'])
return flask.render_template('channels.html', channels=channels)
@site.route('/channel/<channel_id>')
@site.route('/channel/<channel_id>/<download_filter>')
def get_channel(channel_id, download_filter=None):
channel = youtube.get_channel(channel_id)
if channel is None:
flask.abort(404)
videos = youtube.get_videos(channel_id=channel_id)
if download_filter is not None:
videos = [video for video in videos if video['download'] == download_filter]
for video in videos:
published = video['published']
published = datetime.datetime.utcfromtimestamp(published)
published = published.strftime('%Y %m %d')
video['_published_str'] = published
return flask.render_template('channel.html', channel=channel, videos=videos)
@site.route('/static/<filename>')
def get_static(filename):
filename = filename.replace('\\', os.sep)
filename = filename.replace('/', os.sep)
filename = os.path.join('static', filename)
return flask.send_file(filename)
@site.route('/mark_video_state', methods=['POST'])
def post_mark_video_state():
if 'video_id' not in request.form or 'state' not in request.form:
flask.abort(400)
video_id = request.form['video_id']
state = request.form['state']
try:
youtube.mark_video_state(video_id, state)
except KeyError:
flask.abort(404)
except ValueError:
flask.abort(400)
return make_json_response({'video_id': video_id, 'state': state})
@site.route('/refresh_all_channels', methods=['POST'])
def post_refresh_all_channels():
force = request.form.get('force', False)
force = helpers.truthystring(force)
youtube.refresh_all_channels(force=force)
return make_json_response({})
@site.route('/refresh_channel', methods=['POST'])
def post_refresh_channel():
if 'channel_id' not in request.form:
flask.abort(400)
channel_id = request.form['channel_id']
channel_id = channel_id.strip()
if not channel_id:
flask.abort(400)
force = request.form.get('force', False)
force = helpers.truthystring(force)
youtube.refresh_channel(channel_id, force=force)
return make_json_response({})
@site.route('/start_download', methods=['POST'])
def post_start_download():
if 'video_id' not in request.form:
flask.abort(400)
video_id = request.form['video_id']
video_info = youtube_core.get_video([video_id])
if video_info == []:
flask.abort(404)
for video in video_info:
youtube.download_video(video)
return make_json_response({'video_id': video_id, 'state': 'downloaded'})
if __name__ == '__main__':
pass

81
ytapi.py Normal file
View file

@ -0,0 +1,81 @@
import apiclient.discovery
import datetime
import helpers
class Video:
def __init__(self, snippet):
self.id = snippet['id']
snippet = snippet['snippet']
self.title = snippet['title'] or '[untitled]'
self.description = snippet['description']
self.author_id = snippet['channelId']
self.author_name = snippet['channelTitle']
# Something like '2016-10-01T21:00:01'
self.published_string = snippet['publishedAt']
published = snippet['publishedAt']
published = published.split('.')[0]
published = datetime.datetime.strptime(published, '%Y-%m-%dT%H:%M:%S')
self.published = published.timestamp()
thumbnails = snippet['thumbnails']
best_thumbnail = max(thumbnails, key=lambda x: thumbnails[x]['width'] * thumbnails[x]['height'])
self.thumbnail = thumbnails[best_thumbnail]
class Youtube:
def __init__(self, key):
youtube = apiclient.discovery.build(
developerKey=key,
serviceName='youtube',
version='v3',
)
self.youtube = youtube
def get_user_name(self, uid):
user = self.youtube.channels().list(part='snippet', id=uid).execute()
return user['items'][0]['snippet']['title']
def get_user_videos(self, username=None, uid=None):
if username:
user = self.youtube.channels().list(part='contentDetails', forUsername=username).execute()
else:
user = self.youtube.channels().list(part='contentDetails', id=uid).execute()
upload_playlist = user['items'][0]['contentDetails']['relatedPlaylists']['uploads']
page_token = None
while True:
items = self.youtube.playlistItems().list(
maxResults=50,
pageToken=page_token,
part='contentDetails',
playlistId=upload_playlist,
).execute()
page_token = items.get('nextPageToken', None)
new = [item['contentDetails']['videoId'] for item in items['items']]
count = len(new)
new = self.get_video(new)
new.sort(key=lambda x: x.published, reverse=True)
yield from new
#print('Found %d more, %d total' % (count, len(videos)))
if page_token is None or count < 50:
break
def get_video(self, video_ids):
if isinstance(video_ids, str):
singular = True
video_ids = [video_ids]
else:
singular = False
video_ids = helpers.chunk_sequence(video_ids, 50)
results = []
for chunk in video_ids:
chunk = ','.join(chunk)
data = self.youtube.videos().list(part='snippet', id=chunk).execute()
items = data['items']
results += items
#print('Found %d more, %d total' % (len(items), len(results)))
results = [Video(snippet) for snippet in results]
if singular and len(results) == 1:
return results[0]
return results

23
ytqueue.py Normal file
View file

@ -0,0 +1,23 @@
'''
I was having trouble making my Flask server perform the youtube-dl without
clogging up the other site activities. So instead I'll just have the server
export ytqueue files, which this script will download as a separate process.
Rather than maintaining a text file or database of IDs to be downloaded,
I'm fine with creating each ID as a file and letting the filesystem act
as the to-do list.
'''
import os
import time
YOUTUBE_DL = 'youtube-dlw https://www.youtube.com/watch?v={id}'
while True:
print(time.strftime('%H:%M:%S'), 'Looking for files.')
queue = [f for f in os.listdir() if f.endswith('.ytqueue')]
for filename in queue:
yt_id = filename.split('.')[0]
command = YOUTUBE_DL.format(id=yt_id)
os.system(command)
os.remove(filename)
time.sleep(10)