Big: Object-oriented channels & videos, match Etiquette structure.

- Channels and videos are now objects instead of just dictionaries.
- Copied Etiquette's use of cachemanager mixin to cache those objects.
- Copied Etiquette's use of sql_ methods.
- Copied Etiquette's use of namespaced javascript.
- Copied Etiquette's use of config file.
- Redid video_card css to use grid, better on mobile.
- Improved usage of URL parameters with class=merge_class.
- Wrote some actual content on readme.
This commit is contained in:
voussoir 2020-04-04 15:13:01 -07:00
parent 4689609c97
commit 2f5ec40b89
13 changed files with 681 additions and 318 deletions

View file

@ -1,4 +1,56 @@
Youtube Channel Downloader Youtube Channel Downloader
========================== ==========================
You are responsible for your own `bot.py` file, containing a variable `YOUTUBE_KEY`. YouTubeChannelDownloader creates an SQLite3 database of Youtube channels and their videos, and serves it out of a web server.
## YCDL solves three main problems:
### Metadata archive
The database acts as a permanent archive of video metadata including title, description, duration, view count, and more. Even if a video or channel is deleted from Youtube, you will still have this information. Perfect for never losing track of unlisted videos, too.
The thumbnails, however, are not stored in the database, but you can use `utilities\download_thumbnails.py` to download them.
Note: At this time, refreshing a channel in YCDL will update video titles, descriptions, and view counts with their current values. If you refresh a channel after they have changed their video's title or description you will lose the previous value.
### Easily watch every video on the channel
When I discover a channel, I like to watch through their videos over the course of weeks or months. Within Youtube's own interface, it becomes difficult to know which videos I've watched and which ones I haven't. Scrolling through all of a channel's videos is tough especially if there are many.
In YCDL, videos start off as pending and you can mark them as ignore or download, so the pending page is always your "to-watch" list.
On my Youtube subscription box, I would often press the "hide" button on videos only to find them come back a few days later, and hiding live broadcasts was never reliable. YCDL makes watching my subscriptions much easier.
### Send video IDs to youtube-dl
YCDL does not perform the downloading of videos itself. When you click on the download button, it will create an empty file called `xxxxxxxxxxx.ytqueue` in the directory specified by the `ycdl.json` config file. You can send this ID into youtube-dl in your preferred way.
## Features
- Web interface with video embeds
- "Sub-box" page where newest videos from all channels are listed in order
- Sort videos by date, duration, views, or random
- Background thread will refresh channels over time
- Automark channels as ignore or download
## Your API key
You are responsible for your own `bot.py` file, with a function `get_youtube_key`, called with no arguments, that returns a Youtube API key.
## Screenshots
![2020-04-04_15-27-15](https://user-images.githubusercontent.com/7299570/78462830-ca4f9900-768a-11ea-98c9-a4e622d3da62.png)
![2020-04-04_15-29-25](https://user-images.githubusercontent.com/7299570/78462831-cb80c600-768a-11ea-9ff0-517c231e0469.png)
![2020-04-04_15-36-05](https://user-images.githubusercontent.com/7299570/78462832-cb80c600-768a-11ea-9b86-529e1a22616c.png)
![2020-04-04_15-36-10](https://user-images.githubusercontent.com/7299570/78462833-cc195c80-768a-11ea-9cac-208b8c79cad9.png)
![2020-04-04_15-40-27](https://user-images.githubusercontent.com/7299570/78462834-cc195c80-768a-11ea-942b-e89a3dabe64d.png)
## To do list
- Keep permanent record of titles and descriptions.
- Progress indicator for channel refresh.
- Delete channel from web interface.

View file

@ -21,8 +21,6 @@ def favicon():
@site.route('/channels') @site.route('/channels')
def get_channels(): def get_channels():
channels = common.ycdldb.get_channels() channels = common.ycdldb.get_channels()
for channel in channels:
channel['has_pending'] = common.ycdldb.channel_has_pending(channel['id'])
return flask.render_template('channels.html', channels=channels) return flask.render_template('channels.html', channels=channels)
@site.route('/videos') @site.route('/videos')
@ -55,7 +53,7 @@ def get_channel(channel_id=None, download_filter=None):
search_terms = request.args.get('q', '').lower().strip().replace('+', ' ').split() search_terms = request.args.get('q', '').lower().strip().replace('+', ' ').split()
if search_terms: if search_terms:
videos = [v for v in videos if all(term in v['title'].lower() for term in search_terms)] videos = [v for v in videos if all(term in v.title.lower() for term in search_terms)]
limit = request.args.get('limit', None) limit = request.args.get('limit', None)
if limit is not None: if limit is not None:
@ -66,10 +64,10 @@ def get_channel(channel_id=None, download_filter=None):
pass pass
for video in videos: for video in videos:
published = video['published'] published = video.published
published = datetime.datetime.utcfromtimestamp(published) published = datetime.datetime.utcfromtimestamp(published)
published = published.strftime('%Y %m %d') published = published.strftime('%Y %m %d')
video['_published_str'] = published video._published_str = published
all_states = common.ycdldb.get_all_states() all_states = common.ycdldb.get_all_states()
@ -91,7 +89,8 @@ def post_mark_video_state():
try: try:
video_ids = video_ids.split(',') video_ids = video_ids.split(',')
for video_id in video_ids: for video_id in video_ids:
common.ycdldb.mark_video_state(video_id, state, commit=False) video = common.ycdldb.get_video(video_id)
video.mark_state(state, commit=False)
common.ycdldb.sql.commit() common.ycdldb.sql.commit()
except ycdl.exceptions.NoSuchVideo: except ycdl.exceptions.NoSuchVideo:
@ -129,8 +128,8 @@ def post_refresh_channel():
force = request.form.get('force', False) force = request.form.get('force', False)
force = ycdl.helpers.truthystring(force) force = ycdl.helpers.truthystring(force)
common.ycdldb.add_channel(channel_id, commit=False) channel = common.ycdldb.add_channel(channel_id, commit=False)
common.ycdldb.refresh_channel(channel_id, force=force) channel.refresh(force=force)
return jsonify.make_json_response({}) return jsonify.make_json_response({})
@site.route('/start_download', methods=['POST']) @site.route('/start_download', methods=['POST'])

View file

@ -26,7 +26,7 @@ body
} }
.hidden .hidden
{ {
display: none; display: none !important;
} }
#content_body #content_body
{ {

View file

@ -1,3 +1,6 @@
var common = {};
common.post_example =
function post_example(key, value, callback) function post_example(key, value, callback)
{ {
var url = "/postexample"; var url = "/postexample";
@ -6,11 +9,13 @@ function post_example(key, value, callback)
return post(url, data, callback); return post(url, data, callback);
} }
common.null_callback =
function null_callback() function null_callback()
{ {
return; return;
} }
common.post =
function post(url, data, callback) function post(url, data, callback)
{ {
var request = new XMLHttpRequest(); var request = new XMLHttpRequest();
@ -32,6 +37,7 @@ function post(url, data, callback)
request.send(data); request.send(data);
} }
common.bind_box_to_button =
function bind_box_to_button(box, button) function bind_box_to_button(box, button)
{ {
box.onkeydown=function() box.onkeydown=function()
@ -42,6 +48,7 @@ function bind_box_to_button(box, button)
} }
}; };
} }
common.entry_with_history_hook =
function entry_with_history_hook(box, button) function entry_with_history_hook(box, button)
{ {
//console.log(event.keyCode); //console.log(event.keyCode);
@ -81,3 +88,25 @@ function entry_with_history_hook(box, button)
box.entry_history_pos = -1; box.entry_history_pos = -1;
} }
} }
common.init_atag_merge_params =
function init_atag_merge_params()
{
var as = Array.from(document.getElementsByClassName("merge_params"));
page_params = new URLSearchParams(window.location.search);
as.forEach(function(a){
var a_params = new URLSearchParams(a.search);
var new_params = new URLSearchParams();
page_params.forEach(function(value, key) {new_params.set(key, value); });
a_params.forEach(function(value, key) {new_params.set(key, value); });
a.search = new_params.toString();
a.classList.remove("merge_params");
});
}
common.on_pageload =
function on_pageload()
{
common.init_atag_merge_params();
}
document.addEventListener("DOMContentLoaded", common.on_pageload);

View file

@ -2,7 +2,7 @@
<html> <html>
<head> <head>
{% import "header.html" as header %} {% import "header.html" as header %}
<title>{{channel['name']}}</title> <title>{{channel.name}}</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css">
@ -22,8 +22,16 @@
.video_card .video_card
{ {
position: relative; position: relative;
display: grid;
grid-template:
"thumbnail details toolbox" auto
"embed embed embed" auto
/auto 1fr auto;
grid-gap: 4px;
margin: 8px; margin: 8px;
padding: 10px; padding: 8px;
border-radius: 4px; border-radius: 4px;
border: 1px solid #000; border: 1px solid #000;
} }
@ -48,13 +56,51 @@
background-color: #aaffaa; background-color: #aaffaa;
} }
.video_thumbnail
{
grid-area: thumbnail;
justify-self: center;
}
.video_details
{
grid-area: details;
align-self: center;
/*
margin-right prevents the empty space of the <a> tag from swallowing
click events meant for the video card.
*/
margin-right: auto;
}
.embed_toolbox
{
grid-area: embed;
/*
disabling pointer events on the toolbox prevents it from swallowing click
events meant for the video card. Then we must re-enable them for child
elements so the embed button is still clickable.
This one uses pointer-events instead of margin because margin makes the
whole embed too small.
*/
pointer-events: none;
}
.embed_toolbox *
{
pointer-events: auto;
}
.action_toolbox .action_toolbox
{ {
float: right; grid-area: toolbox;
justify-self: right;
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
position: relative; position: relative;
margin-top: auto;
margin-bottom: auto;
} }
.video_action_dropdown .video_action_dropdown
{ {
z-index: 1; z-index: 1;
@ -87,6 +133,19 @@ https://stackoverflow.com/a/35153397
left: 0; left: 0;
top: 0; top: 0;
} }
@media screen and (max-width: 600px)
{
.video_card
{
grid-template:
"thumbnail"
"details"
"toolbox"
"embed"
/1fr;
}
}
</style> </style>
</head> </head>
@ -95,30 +154,29 @@ https://stackoverflow.com/a/35153397
{{header.make_header()}} {{header.make_header()}}
<div id="content_body"> <div id="content_body">
{% if channel is not none %} {% if channel is not none %}
<span><button class="refresh_button" <span><button class="refresh_button" onclick="refresh_channel('{{channel.id}}', false, function(){location.reload()})">Refresh new videos</button></span>
onclick="refresh_channel('{{channel['id']}}', false, function(){location.reload()})">Refresh new videos</button></span> <span><button class="refresh_button" onclick="refresh_channel('{{channel.id}}', true, function(){location.reload()})">Refresh everything</button></span>
<span><button class="refresh_button"
onclick="refresh_channel('{{channel['id']}}', true, function(){location.reload()})">Refresh everything</button></span> <span><a class="merge_params" href="/channel/{{channel.id}}">All</a></span>
<span><a href="/channel/{{channel['id']}}">All</a> <a href="/channel/{{channel['id']}}{{query_string}}">(?q)</a></span>
{% else %} {% else %}
<span><a href="/videos">All</a> <a href="/videos{{query_string}}">(?q)</a></span> <span><a class="merge_params" href="/videos">All</a></span>
{% endif %} {% endif %}
{% for statename in all_states %} {% for statename in all_states %}
{% if channel is not none %} {% if channel is not none %}
<span><a href="/channel/{{channel['id']}}/{{statename}}">{{statename}}</a> <a href="/channel/{{channel['id']}}/{{statename}}{{query_string}}">(?q)</a></span> <span><a class="merge_params" href="/channel/{{channel.id}}/{{statename}}">{{statename}}</a></span>
{% else %} {% else %}
<span><a href="/videos/{{statename}}">{{statename}}</a> <a href="/videos/{{statename}}{{query_string}}">(?q)</a></span> <span><a class="merge_params" href="/videos/{{statename}}">{{statename}}</a></span>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<p><!-- spacer --></p> <p><!-- spacer --></p>
<span>Sort by <span>Sort by
<a href="?orderby=published">Date</a>, <a class="merge_params" href="?orderby=published">Date</a>,
<a href="?orderby=duration">Duration</a>, <a class="merge_params" href="?orderby=duration">Duration</a>,
<a href="?orderby=views">Views</a>, <a class="merge_params" href="?orderby=views">Views</a>,
<a href="?orderby=random">Random</a> <a class="merge_params" href="?orderby=random">Random</a>
</span> </span>
<center><input type="text" id="search_filter"/></center> <center><input type="text" id="search_filter"/></center>
@ -126,21 +184,24 @@ https://stackoverflow.com/a/35153397
<div id="video_cards"> <div id="video_cards">
{% for video in videos %} {% for video in videos %}
<div id="video_card_{{video['id']}}" <div id="video_card_{{video.id}}"
data-ytid="{{video['id']}}" data-ytid="{{video.id}}"
onclick="onclick_select(event)" onclick="onclick_select(event)"
class="video_card video_card_{{video['download']}}" class="video_card video_card_{{video.download}}"
> >
<img src="http://i3.ytimg.com/vi/{{video['id']}}/default.jpg" height="100px"> <img class="video_thumbnail" src="http://i3.ytimg.com/vi/{{video.id}}/default.jpg" height="100px">
<a class="video_title" href="https://www.youtube.com/watch?v={{video['id']}}">{{video['_published_str']}} - {{video['title']}}</a> <div class="video_details">
<span>({{video['duration'] | seconds_to_hms}})</span> <a class="video_title" href="https://www.youtube.com/watch?v={{video.id}}">{{video._published_str}} - {{video.title}}</a>
<span>({{video['views']}})</span> <span>({{video.duration | seconds_to_hms}})</span>
<span>({{video.views}})</span>
{% if channel is none %} {% if channel is none %}
<a href="/channel/{{video['author_id']}}">({{video.get('author_name', 'Chan')}})</a> <a href="/channel/{{video.author_id}}">({{video.author.name if video.author else video.author_id}})</a>
{% endif %} {% endif %}
</div>
<div class="action_toolbox"> <div class="action_toolbox">
<button <button
{% if video['download'] == "pending" %} {% if video.download == "pending" %}
class="video_action_pending hidden" class="video_action_pending hidden"
{% else %} {% else %}
class="video_action_pending" class="video_action_pending"
@ -149,7 +210,7 @@ https://stackoverflow.com/a/35153397
>Revert to Pending</button> >Revert to Pending</button>
<button <button
{% if video['download'] == "pending" %} {% if video.download == "pending" %}
class="video_action_download" class="video_action_download"
{% else %} {% else %}
class="video_action_download hidden" class="video_action_download hidden"
@ -158,7 +219,7 @@ https://stackoverflow.com/a/35153397
>Download</button> >Download</button>
<button <button
{% if video['download'] == "pending" %} {% if video.download == "pending" %}
class="video_action_ignore" class="video_action_ignore"
{% else %} {% else %}
class="video_action_ignore hidden" class="video_action_ignore hidden"
@ -166,10 +227,10 @@ https://stackoverflow.com/a/35153397
onclick="action_button_passthrough(event, mark_video_state, 'ignored')" onclick="action_button_passthrough(event, mark_video_state, 'ignored')"
>Ignore</button> >Ignore</button>
</div> </div>
<br> <div class="embed_toolbox">
<button class="show_embed_button" onclick="toggle_embed_video('{{video.id}}');">Embed</button> <button class="show_embed_button" onclick="toggle_embed_video('{{video.id}}');">Embed</button>
<button class="hide_embed_button hidden" onclick="toggle_embed_video('{{video.id}}');">Unembed</button> <button class="hide_embed_button hidden" onclick="toggle_embed_video('{{video.id}}');">Unembed</button>
<br> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@ -231,18 +292,19 @@ function toggle_embed_video(video_id)
var video_card = document.getElementById("video_card_" + video_id); var video_card = document.getElementById("video_card_" + video_id);
var show_button = video_card.getElementsByClassName("show_embed_button")[0]; var show_button = video_card.getElementsByClassName("show_embed_button")[0];
var hide_button = video_card.getElementsByClassName("hide_embed_button")[0]; var hide_button = video_card.getElementsByClassName("hide_embed_button")[0];
var embed_toolbox = video_card.getElementsByClassName("embed_toolbox")[0];
var embeds = video_card.getElementsByClassName("video_iframe_holder"); var embeds = video_card.getElementsByClassName("video_iframe_holder");
if (embeds.length == 0) if (embeds.length == 0)
{ {
var html = `<div class="video_iframe_holder"><iframe width="711" height="400" src="https://www.youtube.com/embed/${video_id}" frameborder="0" allow="encrypted-media" allowfullscreen></iframe></div>` var html = `<div class="video_iframe_holder"><iframe width="711" height="400" src="https://www.youtube.com/embed/${video_id}" frameborder="0" allow="encrypted-media" allowfullscreen></iframe></div>`
var embed = html_to_element(html); var embed = html_to_element(html);
video_card.appendChild(embed); embed_toolbox.appendChild(embed);
show_button.classList.add("hidden"); show_button.classList.add("hidden");
hide_button.classList.remove("hidden"); hide_button.classList.remove("hidden");
} }
else else
{ {
video_card.removeChild(embeds[0]); embeds[0].parentElement.removeChild(embeds[0]);
show_button.classList.remove("hidden"); show_button.classList.remove("hidden");
hide_button.classList.add("hidden"); hide_button.classList.add("hidden");
} }
@ -412,7 +474,7 @@ function refresh_channel(channel_id, force, callback)
data = new FormData(); data = new FormData();
data.append("channel_id", channel_id); data.append("channel_id", channel_id);
data.append("force", force) data.append("force", force)
return post(url, data, callback); return common.post(url, data, callback);
} }
function mark_video_state(video_ids, state, callback) function mark_video_state(video_ids, state, callback)
@ -421,7 +483,7 @@ function mark_video_state(video_ids, state, callback)
data = new FormData(); data = new FormData();
data.append("video_ids", video_ids); data.append("video_ids", video_ids);
data.append("state", state); data.append("state", state);
return post(url, data, callback); return common.post(url, data, callback);
} }
function start_download(video_ids, callback) function start_download(video_ids, callback)
@ -429,7 +491,7 @@ function start_download(video_ids, callback)
var url = "/start_download"; var url = "/start_download";
data = new FormData(); data = new FormData();
data.append("video_ids", video_ids); data.append("video_ids", video_ids);
return post(url, data, callback); return common.post(url, data, callback);
} }
</script> </script>
</html> </html>

View file

@ -55,12 +55,12 @@
<button id="new_channel_button" onclick="_new_channel_submit()">Add new channel</button> <button id="new_channel_button" onclick="_new_channel_submit()">Add new channel</button>
</div> </div>
{% for channel in channels %} {% for channel in channels %}
{% if channel['has_pending'] %} {% if channel.has_pending() %}
<div class="channel_card_pending"> <div class="channel_card_pending">
{% else %} {% else %}
<div class="channel_card_downloaded"> <div class="channel_card_downloaded">
{% endif %} {% endif %}
<a href="/channel/{{channel['id']}}">{{channel['name']}}</a> <a href="/channel/{{channel['id']}}/pending">(p)</a> <a href="/channel/{{channel.id}}">{{channel.name}}</a> <a href="/channel/{{channel.id}}/pending">(p)</a>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@ -70,7 +70,7 @@
<script type="text/javascript"> <script type="text/javascript">
var box = document.getElementById('new_channel_textbox'); var box = document.getElementById('new_channel_textbox');
var button = document.getElementById('new_channel_button'); var button = document.getElementById('new_channel_button');
bind_box_to_button(box, button); common.bind_box_to_button(box, button);
function _new_channel_submit() function _new_channel_submit()
{ {
@ -86,7 +86,7 @@ function refresh_channel(channel_id, force, callback)
data = new FormData(); data = new FormData();
data.append("channel_id", channel_id); data.append("channel_id", channel_id);
data.append("force", force) data.append("force", force)
return post(url, data, callback); return common.post(url, data, callback);
} }
function refresh_all_channels(force, callback) function refresh_all_channels(force, callback)
@ -94,7 +94,7 @@ function refresh_all_channels(force, callback)
var url = "/refresh_all_channels"; var url = "/refresh_all_channels";
data = new FormData(); data = new FormData();
data.append("force", force) data.append("force", force)
return post(url, data, callback); return common.post(url, data, callback);
} }
</script> </script>
</html> </html>

View file

@ -1,5 +1,5 @@
import logging import logging
logging.getLogger('googleapicliet.discovery_cache').setLevel(logging.ERROR) logging.getLogger('googleapiclient.discovery_cache').setLevel(logging.ERROR)
import gevent.monkey import gevent.monkey
gevent.monkey.patch_all() gevent.monkey.patch_all()

View file

@ -5,22 +5,21 @@ import bot3 as bot
import os import os
import traceback import traceback
import ycdl import ycdl
import ycdl_repl
from voussoirkit import downloady from voussoirkit import downloady
youtube_core = ycdl.ytapi.Youtube(bot.get_youtube_key()) youtube_core = ycdl.ytapi.Youtube(bot.get_youtube_key())
youtube = ycdl.YCDL(youtube_core) ycdldb = ycdl.ycdldb.YCDLDB(youtube_core)
DIRECTORY = '.\\youtube thumbnails' DIRECTORY = '.\\youtube thumbnails'
videos = ycdl_repl.ydl.get_videos() videos = ycdldb.get_videos()
for video in videos: for video in videos:
try: try:
thumbnail_path = os.path.join(DIRECTORY, video['id']) + '.jpg' thumbnail_path = os.path.join(DIRECTORY, video.id) + '.jpg'
if os.path.exists(thumbnail_path): if os.path.exists(thumbnail_path):
continue continue
result = downloady.download_file(video['thumbnail'], thumbnail_path) result = downloady.download_file(video.thumbnail, thumbnail_path)
print(result) print(result)
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()

55
ycdl/constants.py Normal file
View file

@ -0,0 +1,55 @@
from voussoirkit import sqlhelpers
DATABASE_VERSION = 4
DB_VERSION_PRAGMA = f'''
PRAGMA user_version = {DATABASE_VERSION};
'''
DB_PRAGMAS = f'''
PRAGMA count_changes = OFF;
PRAGMA cache_size = 10000;
'''
DB_INIT = f'''
BEGIN;
----------------------------------------------------------------------------------------------------
{DB_PRAGMAS}
{DB_VERSION_PRAGMA}
CREATE TABLE IF NOT EXISTS channels(
id TEXT,
name TEXT,
directory TEXT COLLATE NOCASE,
automark TEXT
);
CREATE TABLE IF NOT EXISTS videos(
id TEXT,
published INT,
author_id TEXT,
title TEXT,
description TEXT,
duration INT,
views INT,
thumbnail TEXT,
download TEXT
);
CREATE INDEX IF NOT EXISTS index_channel_id on channels(id);
CREATE INDEX IF NOT EXISTS index_video_author on videos(author_id);
CREATE INDEX IF NOT EXISTS index_video_author_download on videos(author_id, download);
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);
----------------------------------------------------------------------------------------------------
COMMIT;
'''
SQL_COLUMNS = sqlhelpers.extract_table_column_map(DB_INIT)
SQL_INDEX = sqlhelpers.reverse_table_column_map(SQL_COLUMNS)
DEFAULT_DATADIR = '.'
DEFAULT_DBNAME = 'ycdl.db'
DEFAULT_CONFIGNAME = 'ycdl.json'
DEFAULT_CONFIGURATION = {
'download_directory': '.',
}

View file

@ -46,8 +46,23 @@ class YCDLException(Exception, metaclass=ErrorTypeAdder):
class InvalidVideoState(YCDLException): class InvalidVideoState(YCDLException):
error_message = '{} is not a valid state.' error_message = '{} is not a valid state.'
# NO SUCH
class NoSuchChannel(YCDLException):
error_message = 'Channel {} does not exist.'
class NoSuchVideo(YCDLException): class NoSuchVideo(YCDLException):
error_message = 'Video {} does not exist.' error_message = 'Video {} does not exist.'
# SQL ERRORS
class BadSQL(YCDLException):
pass
class BadTable(BadSQL):
error_message = 'Table "{}" does not exist.'
# GENERAL ERRORS
class DatabaseOutOfDate(YCDLException): class DatabaseOutOfDate(YCDLException):
error_message = 'Database is out-of-date. {current} should be {new}.' error_message = 'Database is out-of-date. {current} should be {new}.'

97
ycdl/objects.py Normal file
View file

@ -0,0 +1,97 @@
from . import constants
from . import exceptions
def normalize_db_row(db_row, table):
if isinstance(db_row, (list, tuple)):
db_row = dict(zip(constants.SQL_COLUMNS[table], db_row))
return db_row
class Base:
def __init__(self, ycdldb):
super().__init__()
self.ycdldb = ycdldb
class Channel(Base):
table = 'channels'
def __init__(self, ycdldb, db_row):
super().__init__(ycdldb)
db_row = normalize_db_row(db_row, self.table)
self.id = db_row['id']
self.name = db_row['name']
self.directory = db_row['directory']
self.automark = db_row['automark']
def has_pending(self):
query = 'SELECT 1 FROM videos WHERE author_id == ? AND download == "pending" LIMIT 1'
bindings = [self.id]
return self.ycdldb.sql_select_one(query, bindings) is not None
def refresh(self, force=False, commit=True):
seen_ids = set()
video_generator = self.ycdldb.youtube.get_user_videos(uid=self.id)
self.ycdldb.log.debug('Refreshing channel: %s', self.id)
for video in video_generator:
seen_ids.add(video.id)
status = self.ycdldb.insert_video(video, commit=False)
video = status['video']
if status['new'] and self.automark is not None:
video.mark_state(self.automark, commit=False)
if self.automark == 'downloaded':
self.ycdldb.download_video(video.id, commit=False)
if not force and not status['new']:
break
if force:
known_videos = self.ycdldb.get_videos(channel_id=self.id)
known_ids = {v.id for v in known_videos}
refresh_ids = list(known_ids.difference(seen_ids))
for video in self.ycdldb.youtube.get_video(refresh_ids):
self.ycdldb.insert_video(video, commit=False)
if commit:
self.ycdldb.commit()
class Video(Base):
table = 'videos'
def __init__(self, ycdldb, db_row):
super().__init__(ycdldb)
db_row = normalize_db_row(db_row, self.table)
self.id = db_row['id']
self.published = db_row['published']
self.author_id = db_row['author_id']
self.title = db_row['title']
self.description = db_row['description']
self.duration = db_row['duration']
self.views = db_row['views']
self.thumbnail = db_row['thumbnail']
self.download = db_row['download']
@property
def author(self):
try:
return self.ycdldb.get_channel(self.author_id)
except exceptions.NoSuchChannel:
return None
def mark_state(self, state, commit=True):
'''
Mark the video as ignored, pending, or downloaded.
'''
if state not in ['ignored', 'pending', 'downloaded']:
raise exceptions.InvalidVideoState(state)
pairs = {
'id': self.id,
'download': state,
}
self.download = state
self.ycdldb.sql_update(table='videos', pairs=pairs, where_key='id')
if commit:
self.ycdldb.commit()

View file

@ -1,148 +1,111 @@
import json
import logging import logging
import os import os
import sqlite3 import sqlite3
import traceback import traceback
from . import constants
from . import exceptions from . import exceptions
from . import helpers from . import objects
from . import ytapi from . import ytapi
from voussoirkit import cacheclass
from voussoirkit import configlayers
from voussoirkit import pathclass from voussoirkit import pathclass
from voussoirkit import sqlhelpers from voussoirkit import sqlhelpers
def YOUTUBE_DL_COMMAND(video_id): def YOUTUBE_DL_COMMAND(video_id):
path = f'D:\\Incoming\\ytqueue\\{video_id}.ytqueue' path = f'{video_id}.ytqueue'
open(path, 'w') open(path, 'w')
logging.basicConfig(level=logging.DEBUG) logging.basicConfig()
log = logging.getLogger(__name__)
logging.getLogger('googleapiclient.discovery').setLevel(logging.WARNING) logging.getLogger('googleapiclient.discovery').setLevel(logging.WARNING)
logging.getLogger('requests.packages.urllib3.connectionpool').setLevel(logging.WARNING) logging.getLogger('requests.packages.urllib3.connectionpool').setLevel(logging.WARNING)
logging.getLogger('requests.packages.urllib3.util.retry').setLevel(logging.WARNING) logging.getLogger('requests.packages.urllib3.util.retry').setLevel(logging.WARNING)
DATABASE_VERSION = 4
DB_VERSION_PRAGMA = '''
PRAGMA user_version = {user_version};
'''
DB_PRAGMAS = '''
PRAGMA count_changes = OFF;
PRAGMA cache_size = 10000;
'''
DB_INIT = f'''
BEGIN;
----------------------------------------------------------------------------------------------------
{DB_PRAGMAS}
{DB_VERSION_PRAGMA}
CREATE TABLE IF NOT EXISTS channels(
id TEXT,
name TEXT,
directory TEXT COLLATE NOCASE,
automark TEXT
);
CREATE TABLE IF NOT EXISTS videos(
id TEXT,
published INT,
author_id TEXT,
title TEXT,
description TEXT,
duration INT,
views INT,
thumbnail TEXT,
download TEXT
);
CREATE INDEX IF NOT EXISTS index_channel_id on channels(id); class YCDLDBCacheManagerMixin:
CREATE INDEX IF NOT EXISTS index_video_author on videos(author_id); _THING_CLASSES = {
CREATE INDEX IF NOT EXISTS index_video_author_download on videos(author_id, download); 'channel':
CREATE INDEX IF NOT EXISTS index_video_id on videos(id); {
CREATE INDEX IF NOT EXISTS index_video_published on videos(published); 'class': objects.Channel,
CREATE INDEX IF NOT EXISTS index_video_download on videos(download); 'exception': exceptions.NoSuchChannel,
---------------------------------------------------------------------------------------------------- },
COMMIT; 'video':
'''.format(user_version=DATABASE_VERSION) {
'class': objects.Video,
'exception': exceptions.NoSuchVideo,
},
}
SQL_CHANNEL_COLUMNS = [ def __init__(self):
'id', super().__init__()
'name',
'directory',
'automark',
]
SQL_VIDEO_COLUMNS = [ def get_cached_instance(self, thing_type, db_row):
'id',
'published',
'author_id',
'title',
'description',
'duration',
'views',
'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)}
DEFAULT_DBNAME = 'ycdl.db'
def assert_is_abspath(path):
'''
TO DO: Determine whether this is actually correct.
'''
if os.path.abspath(path) != path:
raise ValueError('Not an abspath')
class YCDLDB:
def __init__(
self,
youtube,
database_filename=None,
youtube_dl_function=None,
skip_version_check=False,
):
self.youtube = youtube
if database_filename is None:
database_filename = DEFAULT_DBNAME
self.database_filepath = pathclass.Path(database_filename)
existing_database = self.database_filepath.exists
self.sql = sqlite3.connect(database_filename)
self.cur = self.sql.cursor()
if existing_database:
if not skip_version_check:
self._check_version()
self._load_pragmas()
else:
self._first_time_setup()
if youtube_dl_function:
self.youtube_dl_function = youtube_dl_function
else:
self.youtube_dl_function = YOUTUBE_DL_COMMAND
def _check_version(self):
''' '''
Compare database's user_version against DATABASE_VERSION, Check if there is already an instance in the cache and return that.
raising exceptions.DatabaseOutOfDate if not correct. Otherwise, a new instance is created, cached, and returned.
Note that in order to call this method you have to already have a
db_row which means performing some select. If you only have the ID,
use get_thing_by_id, as there may already be a cached instance to save
you the select.
''' '''
existing = self.sql.execute('PRAGMA user_version').fetchone()[0] thing_map = self._THING_CLASSES[thing_type]
if existing != DATABASE_VERSION:
raise exceptions.DatabaseOutOfDate(
existing=existing,
new=DATABASE_VERSION,
filepath=self.database_filepath,
)
def _first_time_setup(self): thing_class = thing_map['class']
self.sql.executescript(DB_INIT) thing_table = thing_class.table
self.sql.commit() thing_cache = self.caches[thing_type]
def _load_pragmas(self): if isinstance(db_row, dict):
self.sql.executescript(DB_PRAGMAS) thing_id = db_row['id']
self.sql.commit() else:
thing_index = constants.SQL_INDEX[thing_table]
thing_id = db_row[thing_index['id']]
try:
thing = thing_cache[thing_id]
except KeyError:
thing = thing_class(self, db_row)
thing_cache[thing_id] = thing
return thing
def get_thing_by_id(self, thing_type, thing_id):
'''
This method will first check the cache to see if there is already an
instance with that ID, in which case we don't need to perform any SQL
select. If it is not in the cache, then a new instance is created,
cached, and returned.
'''
thing_map = self._THING_CLASSES[thing_type]
thing_class = thing_map['class']
if isinstance(thing_id, thing_class):
# This could be used to check if your old reference to an object is
# still in the cache, or re-select it from the db to make sure it
# still exists and re-cache.
# Probably an uncommon need but... no harm I think.
thing_id = thing_id.id
thing_cache = self.caches[thing_type]
try:
return thing_cache[thing_id]
except KeyError:
pass
query = f'SELECT * FROM {thing_class.table} WHERE id == ?'
bindings = [thing_id]
thing_row = self.sql_select_one(query, bindings)
if thing_row is None:
raise thing_map['exception'](thing_id)
thing = thing_class(self, thing_row)
thing_cache[thing_id] = thing
return thing
class YCDLDBChannelMixin:
def __init__(self):
super().__init__()
def add_channel( def add_channel(
self, self,
@ -153,113 +116,170 @@ class YCDLDB:
get_videos=False, get_videos=False,
name=None, name=None,
): ):
if self.get_channel(channel_id) is not None: try:
return return self.get_channel(channel_id)
except exceptions.NoSuchChannel:
pass
if name is None: if name is None:
name = self.youtube.get_user_name(channel_id) name = self.youtube.get_user_name(channel_id)
if download_directory is not None: if download_directory is not None:
assert_is_abspath(download_directory) download_directory = pathclass.Path(download_directory).absolute_path
data = { data = {
'id': channel_id, 'id': channel_id,
'name': name, 'name': name,
'directory': download_directory, 'directory': download_directory,
'automark': None,
} }
self.sql_insert(table='channels', data=data)
(qmarks, bindings) = sqlhelpers.insert_filler(SQL_CHANNEL, data) channel = self.get_cached_instance('channel', data)
query = f'INSERT INTO channels VALUES({qmarks})'
self.cur.execute(query)
if get_videos: if get_videos:
self.refresh_channel(channel_id, commit=False) channel.refresh(commit=False)
if commit: if commit:
self.sql.commit() self.commit()
return channel
return data def get_channel(self, channel_id):
return self.get_thing_by_id('channel', channel_id)
def channel_has_pending(self, channel_id): def get_channels(self):
query = 'SELECT 1 FROM videos WHERE author_id == ? AND download == "pending" LIMIT 1' query = 'SELECT * FROM channels'
self.cur.execute(query, [channel_id]) rows = self.sql_select(query)
return self.cur.fetchone() is not None channels = [self.get_cached_instance('channel', row) for row in rows]
channels.sort(key=lambda c: c.name)
return channels
def refresh_all_channels(self, force=False, skip_failures=False, commit=True):
exceptions = []
for channel in self.get_channels():
try:
channel.refresh(force=force, commit=commit)
except Exception as exc:
if skip_failures:
traceback.print_exc()
exceptions.append(exc)
else:
raise
if commit:
self.commit()
return exceptions
class YCDLSQLMixin:
def __init__(self):
super().__init__()
self._cached_sql_tables = None
def assert_table_exists(self, table):
if not self._cached_sql_tables:
self._cached_sql_tables = self.get_sql_tables()
if table not in self._cached_sql_tables:
raise exceptions.BadTable(table)
def commit(self, message=None):
if message is not None:
self.log.debug('Committing - %s.', message)
self.sql.commit()
def get_sql_tables(self):
query = 'SELECT name FROM sqlite_master WHERE type = "table"'
cur = self.sql_execute(query)
tables = set(row[0] for row in cur.fetchall())
return tables
def rollback(self):
self.log.debug('Rolling back.')
self.sql_execute('ROLLBACK')
def sql_delete(self, table, pairs):
self.assert_table_exists(table)
(qmarks, bindings) = sqlhelpers.delete_filler(pairs)
query = f'DELETE FROM {table} {qmarks}'
self.sql_execute(query, bindings)
def sql_execute(self, query, bindings=[]):
if bindings is None:
bindings = []
cur = self.sql.cursor()
#self.log.log(1, f'{query} {bindings}')
cur.execute(query, bindings)
return cur
def sql_insert(self, table, data):
self.assert_table_exists(table)
column_names = constants.SQL_COLUMNS[table]
(qmarks, bindings) = sqlhelpers.insert_filler(column_names, data)
query = f'INSERT INTO {table} VALUES({qmarks})'
self.sql_execute(query, bindings)
def sql_select(self, query, bindings=None):
cur = self.sql_execute(query, bindings)
while True:
fetch = cur.fetchone()
if fetch is None:
break
yield fetch
def sql_select_one(self, query, bindings=None):
cur = self.sql_execute(query, bindings)
return cur.fetchone()
def sql_update(self, table, pairs, where_key):
self.assert_table_exists(table)
(qmarks, bindings) = sqlhelpers.update_filler(pairs, where_key=where_key)
query = f'UPDATE {table} {qmarks}'
self.sql_execute(query, bindings)
class YCDLDBVideoMixin:
def __init__(self):
super().__init__()
def download_video(self, video, commit=True, force=False): def download_video(self, video, commit=True, force=False):
''' '''
Execute the `YOUTUBE_DL_COMMAND`, within the channel's associated Execute the `YOUTUBE_DL_COMMAND`, within the channel's associated
directory if applicable. directory if applicable.
''' '''
# This logic is a little hazier than I would like, but it's all in the
# interest of minimizing unnecessary API calls.
if isinstance(video, ytapi.Video): if isinstance(video, ytapi.Video):
video_id = video.id video_id = video.id
else: else:
video_id = video video_id = video
self.cur.execute('SELECT * FROM videos WHERE id == ?', [video_id])
video_row = self.cur.fetchone()
if video_row is None:
# Since the video was not in the db, we may not know about the channel either.
if not isinstance(video, ytapi.Video):
print('get video')
video = self.youtube.get_video(video)
channel_id = video.author_id
self.cur.execute('SELECT * FROM channels WHERE id == ?', [channel_id])
if self.cur.fetchone() is None:
print('add channel')
self.add_channel(channel_id, get_videos=False, commit=False)
video_row = self.insert_video(video, commit=False)['row']
else:
channel_id = video_row[SQL_VIDEO['author_id']]
if video_row[SQL_VIDEO['download']] != 'pending' and not force: video = self.get_video(video_id)
if video.download != 'pending' and not force:
print('That video does not need to be downloaded.') print('That video does not need to be downloaded.')
return return
current_directory = os.getcwd() try:
download_directory = self.get_channel(channel_id)['directory'] channel = self.get_channel(video.author_id)
download_directory = download_directory or current_directory download_directory = channel.directory
download_directory = download_directory or self.config['download_directory']
except exceptions.NoSuchChannel:
download_directory = self.config['download_directory']
os.makedirs(download_directory, exist_ok=True) os.makedirs(download_directory, exist_ok=True)
current_directory = os.getcwd()
os.chdir(download_directory) os.chdir(download_directory)
self.youtube_dl_function(video_id) self.youtube_dl_function(video_id)
os.chdir(current_directory) os.chdir(current_directory)
self.cur.execute('UPDATE videos SET download = "downloaded" WHERE id == ?', [video_id]) pairs = {
'id': video_id,
'download': 'downloaded',
}
self.sql_update(table='videos', pairs=pairs, where_key='id')
if commit: if commit:
self.sql.commit() self.commit()
def get_all_states(self):
query = 'SELECT DISTINCT download FROM videos'
self.cur.execute(query)
states = self.cur.fetchall()
if states is None:
return []
states = [row[0] for row in states]
return sorted(states)
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_video(self, video_id): def get_video(self, video_id):
self.cur.execute('SELECT * FROM videos WHERE id == ?', [video_id]) return self.get_thing_by_id('video', video_id)
video = self.cur.fetchone()
video = {key: video[SQL_VIDEO[key]] for key in SQL_VIDEO}
return video
def get_videos(self, channel_id=None, *, download_filter=None, orderby=None): def get_videos(self, channel_id=None, *, download_filter=None, orderby=None):
wheres = [] wheres = []
@ -294,23 +314,8 @@ class YCDLDB:
orderbys = ' ORDER BY ' + orderbys orderbys = ' ORDER BY ' + orderbys
query = 'SELECT * FROM videos' + wheres + orderbys query = 'SELECT * FROM videos' + wheres + orderbys
self.cur.execute(query, bindings) rows = self.sql_select(query, bindings)
rows = self.cur.fetchall() videos = [self.get_cached_instance('video', row) for row in rows]
if not rows:
return []
videos = []
channels = {}
for row in rows:
video = {key: row[SQL_VIDEO[key]] for key in SQL_VIDEO}
author_id = video['author_id']
if author_id in channels:
video['author_name'] = channels[author_id]
author = self.get_channel(author_id)
if author:
channels[author_id] = author['name']
video['author_name'] = author['name']
videos.append(video)
return videos return videos
def insert_playlist(self, playlist_id, commit=True): def insert_playlist(self, playlist_id, commit=True):
@ -318,7 +323,7 @@ class YCDLDB:
results = [self.insert_video(video, commit=False) for video in video_generator] results = [self.insert_video(video, commit=False) for video in video_generator]
if commit: if commit:
self.sql.commit() self.commit()
return results return results
@ -328,12 +333,13 @@ class YCDLDB:
if add_channel: if add_channel:
self.add_channel(video.author_id, get_videos=False, commit=False) 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() try:
existing = fetch is not None existing = self.get_video(video.id)
download_status = existing.download
download_status = 'pending' if not existing else fetch[SQL_VIDEO['download']] except exceptions.NoSuchVideo:
existing = None
download_status = 'pending'
data = { data = {
'id': video.id, 'id': video.id,
@ -348,72 +354,116 @@ class YCDLDB:
} }
if existing: if existing:
(qmarks, bindings) = sqlhelpers.update_filler(data, where_key='id') self.sql_update(table='videos', pairs=data, where_key='id')
query = f'UPDATE videos {qmarks}'
else: else:
(qmarks, bindings) = sqlhelpers.insert_filler(SQL_VIDEO_COLUMNS, data) self.sql_insert(table='videos', data=data)
query = f'INSERT INTO videos VALUES({qmarks})'
self.cur.execute(query, bindings) video = self.get_cached_instance('video', data)
if commit: if commit:
self.sql.commit() self.commit()
return {'new': not existing, 'row': data} return {'new': not existing, 'video': video}
def mark_video_state(self, video_id, state, commit=True): class YCDLDB(
YCDLDBCacheManagerMixin,
YCDLDBChannelMixin,
YCDLDBVideoMixin,
YCDLSQLMixin,
):
def __init__(
self,
youtube,
data_directory=None,
youtube_dl_function=None,
skip_version_check=False,
):
super().__init__()
self.youtube = youtube
# DATA DIR PREP
if data_directory is None:
data_directory = constants.DEFAULT_DATADIR
self.data_directory = pathclass.Path(data_directory)
# LOGGING
self.log = logging.getLogger(__name__)
self.log.setLevel(logging.DEBUG)
# DATABASE
self.database_filepath = self.data_directory.with_child(constants.DEFAULT_DBNAME)
existing_database = self.database_filepath.exists
self.sql = sqlite3.connect(self.database_filepath.absolute_path)
if existing_database:
if not skip_version_check:
self._check_version()
self._load_pragmas()
else:
self._first_time_setup()
# DOWNLOAD COMMAND
if youtube_dl_function:
self.youtube_dl_function = youtube_dl_function
else:
self.youtube_dl_function = YOUTUBE_DL_COMMAND
# CONFIG
self.config_filepath = self.data_directory.with_child(constants.DEFAULT_CONFIGNAME)
self.load_config()
self.caches = {
'channel': cacheclass.Cache(maxlen=20_000),
'video': cacheclass.Cache(maxlen=50_000),
}
def _check_version(self):
''' '''
Mark the video as ignored, pending, or downloaded. Compare database's user_version against constants.DATABASE_VERSION,
raising exceptions.DatabaseOutOfDate if not correct.
''' '''
if state not in ['ignored', 'pending', 'downloaded', 'coldstorage']: existing = self.sql.execute('PRAGMA user_version').fetchone()[0]
raise exceptions.InvalidVideoState(state) if existing != constants.DATABASE_VERSION:
self.cur.execute('SELECT * FROM videos WHERE id == ?', [video_id]) raise exceptions.DatabaseOutOfDate(
if self.cur.fetchone() is None: existing=existing,
raise exceptions.NoSuchVideo(video_id) new=constants.DATABASE_VERSION,
self.cur.execute('UPDATE videos SET download = ? WHERE id == ?', [state, video_id]) filepath=self.database_filepath,
if commit: )
self.sql.commit()
def refresh_all_channels(self, force=False, skip_failures=False, commit=True): def _first_time_setup(self):
exceptions = [] self.log.debug('Running first-time database setup.')
for channel in self.get_channels(): self.sql.executescript(constants.DB_INIT)
try: self.commit()
self.refresh_channel(channel, force=force, commit=commit)
except Exception as exc:
if skip_failures:
traceback.print_exc()
exceptions.append(exc)
else:
raise
if commit:
self.sql.commit()
return exceptions
def refresh_channel(self, channel, force=False, commit=True): def _load_pragmas(self):
if isinstance(channel, str): self.log.debug('Reloading pragmas.')
channel = self.get_channel(channel) self.sql.executescript(constants.DB_PRAGMAS)
self.commit()
seen_ids = set() def get_all_states(self):
video_generator = self.youtube.get_user_videos(uid=channel['id']) '''
log.debug('Refreshing channel: %s', channel['id']) Get a list of all the different `download` states that are currently in
for video in video_generator: use in the database.
seen_ids.add(video.id) '''
status = self.insert_video(video, commit=False) # Note: This function was added while I was considering the addition of
# arbitrarily many states for user-defined purposes, but I kind of went
# back on that so I'm not sure if it will be useful.
query = 'SELECT DISTINCT download FROM videos'
states = self.sql_select(query)
states = [row[0] for row in states]
return sorted(states)
if status['new'] and channel['automark'] is not None: def load_config(self):
self.mark_video_state(video.id, channel['automark'], commit=False) (config, needs_rewrite) = configlayers.load_file(
if channel['automark'] == 'downloaded': filepath=self.config_filepath,
self.download_video(video.id, commit=False) defaults=constants.DEFAULT_CONFIGURATION,
)
self.config = config
if not force and not status['new']: if needs_rewrite:
break self.save_config()
if force: def save_config(self):
known_videos = self.get_videos(channel_id=channel['id']) with open(self.config_filepath.absolute_path, 'w', encoding='utf-8') as handle:
known_ids = {v['id'] for v in known_videos} handle.write(json.dumps(self.config, indent=4, sort_keys=True))
refresh_ids = list(known_ids.difference(seen_ids))
for video in self.youtube.get_video(refresh_ids):
self.insert_video(video, commit=False)
if commit:
self.sql.commit()

View file

@ -75,6 +75,8 @@ class Youtube:
videos = self.get_video(video_ids) videos = self.get_video(video_ids)
videos.sort(key=lambda x: x.published, reverse=True) videos.sort(key=lambda x: x.published, reverse=True)
log.debug('Got %d more videos.', len(videos))
for video in videos: for video in videos:
yield video yield video
@ -118,7 +120,10 @@ class Youtube:
chunks = helpers.chunk_sequence(video_ids, 50) chunks = helpers.chunk_sequence(video_ids, 50)
for chunk in chunks: for chunk in chunks:
chunk = ','.join(chunk) chunk = ','.join(chunk)
data = self.youtube.videos().list(part='id,contentDetails,snippet,statistics', id=chunk).execute() data = self.youtube.videos().list(
part='id,contentDetails,snippet,statistics',
id=chunk,
).execute()
items = data['items'] items = data['items']
snippets.extend(items) snippets.extend(items)
videos = [] videos = []