checkpoint
This commit is contained in:
commit
bd263b2ed7
17 changed files with 1223 additions and 0 deletions
4
README.md
Normal file
4
README.md
Normal 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
31
helpers.py
Normal 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
3
requirements.txt
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
flask
|
||||||
|
gevent
|
||||||
|
https://github.com/voussoir/else/raw/master/_voussoirkit/voussoirkit.zip
|
32
static/common.css
Normal file
32
static/common.css
Normal 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
83
static/common.js
Normal 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
BIN
static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.4 KiB |
138
static/favicon.svg
Normal file
138
static/favicon.svg
Normal 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
224
templates/channel.html
Normal 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
96
templates/channels.html
Normal 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
6
templates/header.html
Normal 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
28
templates/root.html
Normal 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
239
ycdl.py
Normal 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
11
ycdl_easy.py
Normal 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
29
ycdl_launch.py
Normal 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
195
ycdl_site.py
Normal 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
81
ytapi.py
Normal 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
23
ytqueue.py
Normal 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)
|
Loading…
Reference in a new issue