Use worms, add more ycli functions.
This commit is contained in:
parent
0adeb55790
commit
c22f20fcf8
15 changed files with 562 additions and 398 deletions
242
frontends/ycdl_cli.py
Normal file
242
frontends/ycdl_cli.py
Normal file
|
@ -0,0 +1,242 @@
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from voussoirkit import betterhelp
|
||||||
|
from voussoirkit import interactive
|
||||||
|
from voussoirkit import pipeable
|
||||||
|
from voussoirkit import vlogging
|
||||||
|
from voussoirkit import operatornotify
|
||||||
|
|
||||||
|
import ycdl
|
||||||
|
|
||||||
|
log = vlogging.getLogger(__name__, 'ycdl_cli')
|
||||||
|
|
||||||
|
# HELPERS ##########################################################################################
|
||||||
|
|
||||||
|
def closest_db():
|
||||||
|
return ycdl.ycdldb.YCDLDB.closest_ycdldb()
|
||||||
|
|
||||||
|
# ARGPARSE #########################################################################################
|
||||||
|
|
||||||
|
def add_channel_argparse(args):
|
||||||
|
ycdldb = closest_db()
|
||||||
|
ycdldb.add_channel(
|
||||||
|
channel_id=args.channel_id,
|
||||||
|
automark=args.automark,
|
||||||
|
download_directory=args.download_directory,
|
||||||
|
get_videos=args.get_videos,
|
||||||
|
name=args.name,
|
||||||
|
queuefile_extension=args.queuefile_extension,
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.autoyes or interactive.getpermission('Commit?'):
|
||||||
|
ycdldb.commit()
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def channel_list_argparse(args):
|
||||||
|
ycdldb = closest_db()
|
||||||
|
channels = sorted(ycdldb.get_channels(), key=lambda c: c.name.lower())
|
||||||
|
for channel in channels:
|
||||||
|
line = args.format.format(
|
||||||
|
automark=channel.automark,
|
||||||
|
autorefresh=channel.autorefresh,
|
||||||
|
id=channel.id,
|
||||||
|
name=channel.name,
|
||||||
|
queuefile_extension=channel.queuefile_extension,
|
||||||
|
uploads_playlist=channel.uploads_playlist,
|
||||||
|
)
|
||||||
|
pipeable.stdout(line)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def delete_channel_argparse(args):
|
||||||
|
ycdldb = closest_db()
|
||||||
|
for channel_id in args.channel_id:
|
||||||
|
channel = ycdldb.get_channel(channel_id)
|
||||||
|
channel.delete()
|
||||||
|
|
||||||
|
if args.autoyes or interactive.getpermission('Commit?'):
|
||||||
|
ycdldb.commit()
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def init_argparse(args):
|
||||||
|
ycdldb = ycdl.ycdldb.YCDLDB(create=True)
|
||||||
|
ycdldb.commit()
|
||||||
|
pipeable.stdout(ycdldb.data_directory.absolute_path)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def refresh_channels_argparse(args):
|
||||||
|
needs_commit = False
|
||||||
|
status = 0
|
||||||
|
|
||||||
|
ycdldb = closest_db()
|
||||||
|
if args.channels:
|
||||||
|
channels = [ycdldb.get_channel(c) for c in args.channels]
|
||||||
|
for channel in channels:
|
||||||
|
try:
|
||||||
|
channel.refresh(force=args.force)
|
||||||
|
needs_commit = True
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning(traceback.format_exc())
|
||||||
|
status = 1
|
||||||
|
else:
|
||||||
|
excs = ycdldb.refresh_all_channels(force=args.force, skip_failures=True)
|
||||||
|
needs_commit = True
|
||||||
|
|
||||||
|
if not needs_commit:
|
||||||
|
return status
|
||||||
|
|
||||||
|
if args.autoyes or interactive.getpermission('Commit?'):
|
||||||
|
ycdldb.commit()
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
DOCSTRING = '''
|
||||||
|
YCDL CLI
|
||||||
|
========
|
||||||
|
|
||||||
|
{add_channel}
|
||||||
|
|
||||||
|
{channel_list}
|
||||||
|
|
||||||
|
{delete_channel}
|
||||||
|
|
||||||
|
{init}
|
||||||
|
|
||||||
|
{refresh_channels}
|
||||||
|
|
||||||
|
TO SEE DETAILS ON EACH COMMAND, RUN
|
||||||
|
> ycdl_cli.py <command> --help
|
||||||
|
'''
|
||||||
|
|
||||||
|
SUB_DOCSTRINGS = dict(
|
||||||
|
add_channel='''
|
||||||
|
add_channel:
|
||||||
|
Add a channel to the database.
|
||||||
|
|
||||||
|
> ycdl_cli.py add_channel channel_id <flags>
|
||||||
|
|
||||||
|
flags:
|
||||||
|
--automark X:
|
||||||
|
Set the channel's automark to this value, which should be 'pending',
|
||||||
|
'downloaded', or 'ignored'.
|
||||||
|
|
||||||
|
--download_directory X:
|
||||||
|
Set the channel's download directory to this path, which must
|
||||||
|
be a directory.
|
||||||
|
|
||||||
|
--name X:
|
||||||
|
Override the channel's own name with a name of your choosing.
|
||||||
|
|
||||||
|
--no_videos:
|
||||||
|
By default, the channel's videos will be fetched right away.
|
||||||
|
Add this argument if you don't want to do that yet.
|
||||||
|
|
||||||
|
--queuefile_extension X:
|
||||||
|
Set the queuefile extension for all videos downloaded from this channel.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
> ycdl_cli.py add_channel UCFhXFikryT4aFcLkLw2LBLA
|
||||||
|
'''.strip(),
|
||||||
|
|
||||||
|
channel_list='''
|
||||||
|
channel_list:
|
||||||
|
Print all channels in the database.
|
||||||
|
|
||||||
|
> ycdl_cli.py channel_list <flags>
|
||||||
|
|
||||||
|
--format X:
|
||||||
|
A string like "{id}: {name}" to format the attributes of the channel.
|
||||||
|
The available attributes are id, name, automark, autorefresh,
|
||||||
|
uploads_playlist queuefile_extension.
|
||||||
|
|
||||||
|
> ycdl_cli.py list_channels
|
||||||
|
'''.strip(),
|
||||||
|
|
||||||
|
delete_channel='''
|
||||||
|
delete_channel:
|
||||||
|
Delete a channel and all its videos from the database.
|
||||||
|
|
||||||
|
You can pass multiple channel IDs.
|
||||||
|
|
||||||
|
> ycdl_cli.py delete_channel channel_id [channel_id channel_id]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
> ycdl_cli.py delete_channel UCOYBuFGi8T3NM5fNAptCLCw
|
||||||
|
> ycdl_cli.py delete_channel UCOYBuFGi8T3NM5fNAptCLCw UCmu9PVIZBk-ZCi-Sk2F2utA UCEKJKJ3FO-9SFv5x5BzyxhQ
|
||||||
|
'''.strip(),
|
||||||
|
|
||||||
|
init='''
|
||||||
|
init:
|
||||||
|
Create a new YCDL database in the current directory.
|
||||||
|
|
||||||
|
> ycdl_cli.py init
|
||||||
|
'''.strip(),
|
||||||
|
|
||||||
|
refresh_channels='''
|
||||||
|
refresh_channels:
|
||||||
|
Refresh some or all channels in the database.
|
||||||
|
|
||||||
|
> ycdl_cli.py refresh_channels <flags>
|
||||||
|
|
||||||
|
flags:
|
||||||
|
--channels X Y Z:
|
||||||
|
Any number of channel IDs.
|
||||||
|
|
||||||
|
--force:
|
||||||
|
If omitted, only new videos are downloaded.
|
||||||
|
If included, channels are refreshed completely.
|
||||||
|
Examples:
|
||||||
|
> ycdl_cli.py refresh_channels --force
|
||||||
|
> ycdl_cli.py refresh_channels --channels UC1_uAIS3r8Vu6JjXWvastJg
|
||||||
|
'''
|
||||||
|
)
|
||||||
|
|
||||||
|
DOCSTRING = betterhelp.add_previews(DOCSTRING, SUB_DOCSTRINGS)
|
||||||
|
|
||||||
|
@operatornotify.main_decorator(subject='ycdl_cli')
|
||||||
|
@vlogging.main_decorator
|
||||||
|
def main(argv):
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
subparsers = parser.add_subparsers()
|
||||||
|
|
||||||
|
p_add_channel = subparsers.add_parser('add_channel', aliases=['add-channel'])
|
||||||
|
p_add_channel.add_argument('channel_id')
|
||||||
|
p_add_channel.add_argument('--automark', default='pending')
|
||||||
|
p_add_channel.add_argument('--download_directory', '--download-directory', default=None)
|
||||||
|
p_add_channel.add_argument('--name', default=None)
|
||||||
|
p_add_channel.add_argument('--no_videos', '--no-videos', dest='get_videos', action='store_false')
|
||||||
|
p_add_channel.add_argument('--queuefile_extension', '--queuefile-extension', default=None)
|
||||||
|
p_add_channel.add_argument('--yes', dest='autoyes', action='store_true')
|
||||||
|
p_add_channel.set_defaults(func=add_channel_argparse)
|
||||||
|
|
||||||
|
p_channel_list = subparsers.add_parser('channel_list', aliases=['channel-list'])
|
||||||
|
p_channel_list.add_argument('--format', default='{id}:{name}')
|
||||||
|
p_channel_list.set_defaults(func=channel_list_argparse)
|
||||||
|
|
||||||
|
p_delete_channel = subparsers.add_parser('delete_channel', aliases=['delete-channel'])
|
||||||
|
p_delete_channel.add_argument('channel_id', nargs='+')
|
||||||
|
p_delete_channel.add_argument('--yes', dest='autoyes', action='store_true')
|
||||||
|
p_delete_channel.set_defaults(func=delete_channel_argparse)
|
||||||
|
|
||||||
|
p_init = subparsers.add_parser('init')
|
||||||
|
p_init.set_defaults(func=init_argparse)
|
||||||
|
|
||||||
|
p_refresh_channels = subparsers.add_parser('refresh_channels', aliases=['refresh-channels'])
|
||||||
|
p_refresh_channels.add_argument('--channels', nargs='*')
|
||||||
|
p_refresh_channels.add_argument('--force', action='store_true')
|
||||||
|
p_refresh_channels.add_argument('--yes', dest='autoyes', action='store_true')
|
||||||
|
p_refresh_channels.set_defaults(func=refresh_channels_argparse)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return betterhelp.subparser_main(argv, parser, DOCSTRING, SUB_DOCSTRINGS)
|
||||||
|
except ycdl.exceptions.NoClosestYCDLDB as exc:
|
||||||
|
pipeable.stderr(exc.error_message)
|
||||||
|
pipeable.stderr('Try `ycdl_cli.py init` to create the database.')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
|
@ -59,7 +59,7 @@ def after_request(response):
|
||||||
|
|
||||||
def init_ycdldb(*args, **kwargs):
|
def init_ycdldb(*args, **kwargs):
|
||||||
global ycdldb
|
global ycdldb
|
||||||
ycdldb = ycdl.ycdldb.YCDLDB(*args, **kwargs)
|
ycdldb = ycdl.ycdldb.YCDLDB.closest_ycdldb(*args, **kwargs)
|
||||||
|
|
||||||
def refresher_thread(rate):
|
def refresher_thread(rate):
|
||||||
while True:
|
while True:
|
||||||
|
|
|
@ -115,7 +115,7 @@ def post_add_channel():
|
||||||
except ycdl.ytapi.ChannelNotFound:
|
except ycdl.ytapi.ChannelNotFound:
|
||||||
return flasktools.json_response({}, status=404)
|
return flasktools.json_response({}, status=404)
|
||||||
|
|
||||||
channel = common.ycdldb.add_channel(channel_id, get_videos=True)
|
channel = common.ycdldb.add_channel(channel_id, get_videos=True, commit=True)
|
||||||
return flasktools.json_response(channel.jsonify())
|
return flasktools.json_response(channel.jsonify())
|
||||||
|
|
||||||
@site.route('/channel/<channel_id>/delete', methods=['POST'])
|
@site.route('/channel/<channel_id>/delete', methods=['POST'])
|
||||||
|
@ -125,7 +125,7 @@ def post_delete_channel(channel_id):
|
||||||
except ycdl.exceptions.NoSuchChannel as exc:
|
except ycdl.exceptions.NoSuchChannel as exc:
|
||||||
return flasktools.json_response(exc.jsonify(), status=404)
|
return flasktools.json_response(exc.jsonify(), status=404)
|
||||||
|
|
||||||
channel.delete()
|
channel.delete(commit=True)
|
||||||
return flasktools.json_response({})
|
return flasktools.json_response({})
|
||||||
|
|
||||||
@site.route('/channel/<channel_id>/refresh', methods=['POST'])
|
@site.route('/channel/<channel_id>/refresh', methods=['POST'])
|
||||||
|
@ -137,14 +137,14 @@ def post_refresh_channel(channel_id):
|
||||||
except ycdl.exceptions.NoSuchChannel as exc:
|
except ycdl.exceptions.NoSuchChannel as exc:
|
||||||
return flasktools.json_response(exc.jsonify(), status=404)
|
return flasktools.json_response(exc.jsonify(), status=404)
|
||||||
|
|
||||||
channel.refresh(force=force)
|
channel.refresh(force=force, commit=True)
|
||||||
return flasktools.json_response(channel.jsonify())
|
return flasktools.json_response(channel.jsonify())
|
||||||
|
|
||||||
@site.route('/refresh_all_channels', methods=['POST'])
|
@site.route('/refresh_all_channels', methods=['POST'])
|
||||||
def post_refresh_all_channels():
|
def post_refresh_all_channels():
|
||||||
force = request.form.get('force', False)
|
force = request.form.get('force', False)
|
||||||
force = stringtools.truthystring(force, False)
|
force = stringtools.truthystring(force, False)
|
||||||
common.ycdldb.refresh_all_channels(force=force, skip_failures=True)
|
common.ycdldb.refresh_all_channels(force=force, skip_failures=True, commit=True)
|
||||||
return flasktools.json_response({})
|
return flasktools.json_response({})
|
||||||
|
|
||||||
@site.route('/channel/<channel_id>/set_automark', methods=['POST'])
|
@site.route('/channel/<channel_id>/set_automark', methods=['POST'])
|
||||||
|
@ -153,7 +153,7 @@ def post_set_automark(channel_id):
|
||||||
channel = common.ycdldb.get_channel(channel_id)
|
channel = common.ycdldb.get_channel(channel_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
channel.set_automark(state)
|
channel.set_automark(state, commit=True)
|
||||||
except ycdl.exceptions.InvalidVideoState:
|
except ycdl.exceptions.InvalidVideoState:
|
||||||
flask.abort(400)
|
flask.abort(400)
|
||||||
|
|
||||||
|
@ -167,7 +167,7 @@ def post_set_autorefresh(channel_id):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
autorefresh = stringtools.truthystring(autorefresh)
|
autorefresh = stringtools.truthystring(autorefresh)
|
||||||
channel.set_autorefresh(autorefresh)
|
channel.set_autorefresh(autorefresh, commit=True)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
flask.abort(400)
|
flask.abort(400)
|
||||||
|
|
||||||
|
@ -179,7 +179,7 @@ def post_set_download_directory(channel_id):
|
||||||
channel = common.ycdldb.get_channel(channel_id)
|
channel = common.ycdldb.get_channel(channel_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
channel.set_download_directory(download_directory)
|
channel.set_download_directory(download_directory, commit=True)
|
||||||
except pathclass.NotDirectory:
|
except pathclass.NotDirectory:
|
||||||
exc = {
|
exc = {
|
||||||
'error_type': 'NOT_DIRECTORY',
|
'error_type': 'NOT_DIRECTORY',
|
||||||
|
@ -196,7 +196,7 @@ def post_set_queuefile_extension(channel_id):
|
||||||
extension = request.form['extension']
|
extension = request.form['extension']
|
||||||
channel = common.ycdldb.get_channel(channel_id)
|
channel = common.ycdldb.get_channel(channel_id)
|
||||||
|
|
||||||
channel.set_queuefile_extension(extension)
|
channel.set_queuefile_extension(extension, commit=True)
|
||||||
|
|
||||||
response = {'queuefile_extension': channel.queuefile_extension}
|
response = {'queuefile_extension': channel.queuefile_extension}
|
||||||
return flasktools.json_response(response)
|
return flasktools.json_response(response)
|
||||||
|
|
|
@ -19,7 +19,7 @@ def post_mark_video_state():
|
||||||
for video_id in video_ids:
|
for video_id in video_ids:
|
||||||
video = common.ycdldb.get_video(video_id)
|
video = common.ycdldb.get_video(video_id)
|
||||||
video.mark_state(state, commit=False)
|
video.mark_state(state, commit=False)
|
||||||
common.ycdldb.sql.commit()
|
common.ycdldb.commit()
|
||||||
|
|
||||||
except ycdl.exceptions.NoSuchVideo:
|
except ycdl.exceptions.NoSuchVideo:
|
||||||
common.ycdldb.rollback()
|
common.ycdldb.rollback()
|
||||||
|
@ -40,7 +40,7 @@ def post_start_download():
|
||||||
video_ids = video_ids.split(',')
|
video_ids = video_ids.split(',')
|
||||||
for video_id in video_ids:
|
for video_id in video_ids:
|
||||||
common.ycdldb.download_video(video_id, commit=False)
|
common.ycdldb.download_video(video_id, commit=False)
|
||||||
common.ycdldb.sql.commit()
|
common.ycdldb.commit()
|
||||||
|
|
||||||
except ycdl.ytapi.VideoNotFound:
|
except ycdl.ytapi.VideoNotFound:
|
||||||
common.ycdldb.rollback()
|
common.ycdldb.rollback()
|
||||||
|
|
|
@ -259,6 +259,8 @@ https://stackoverflow.com/a/35153397
|
||||||
</div> <!-- tab-videos -->
|
</div> <!-- tab-videos -->
|
||||||
|
|
||||||
<div class="tab" data-tab-title="Settings">
|
<div class="tab" data-tab-title="Settings">
|
||||||
|
<div>Channel ID: <code>{{channel.id}}</code></div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label><input type="checkbox" id="set_autorefresh_checkbox" {{"checked" if channel.autorefresh else ""}} onchange="return set_autorefresh_form(event);"/> Automatically refresh this channel regularly.</label>
|
<label><input type="checkbox" id="set_autorefresh_checkbox" {{"checked" if channel.autorefresh else ""}} onchange="return set_autorefresh_form(event);"/> Automatically refresh this channel regularly.</label>
|
||||||
<span id="set_autorefresh_spinner" class="hidden">Working...</span>
|
<span id="set_autorefresh_spinner" class="hidden">Working...</span>
|
||||||
|
|
|
@ -40,7 +40,6 @@ from voussoirkit import vlogging
|
||||||
log = vlogging.getLogger(__name__, 'ycdl_flask_dev')
|
log = vlogging.getLogger(__name__, 'ycdl_flask_dev')
|
||||||
|
|
||||||
import ycdl
|
import ycdl
|
||||||
import youtube_credentials
|
|
||||||
import backend
|
import backend
|
||||||
|
|
||||||
site = backend.site
|
site = backend.site
|
||||||
|
@ -54,7 +53,6 @@ HTTPS_DIR = pathclass.Path(__file__).parent.with_child('https')
|
||||||
|
|
||||||
def ycdl_flask_launch(
|
def ycdl_flask_launch(
|
||||||
*,
|
*,
|
||||||
create,
|
|
||||||
localhost_only,
|
localhost_only,
|
||||||
port,
|
port,
|
||||||
refresh_rate,
|
refresh_rate,
|
||||||
|
@ -79,8 +77,12 @@ def ycdl_flask_launch(
|
||||||
if localhost_only:
|
if localhost_only:
|
||||||
site.localhost_only = True
|
site.localhost_only = True
|
||||||
|
|
||||||
youtube_core = ycdl.ytapi.Youtube(youtube_credentials.get_youtube_key())
|
try:
|
||||||
backend.common.init_ycdldb(youtube_core, create=create)
|
backend.common.init_ycdldb()
|
||||||
|
except ycdl.exceptions.NoClosestYCDLDB as exc:
|
||||||
|
log.error(exc.error_message)
|
||||||
|
log.error('Try `ycdl_cli.py init` to create the database.')
|
||||||
|
return 1
|
||||||
|
|
||||||
message = f'Starting server on port {port}, pid={os.getpid()}.'
|
message = f'Starting server on port {port}, pid={os.getpid()}.'
|
||||||
if use_https:
|
if use_https:
|
||||||
|
@ -100,7 +102,6 @@ def ycdl_flask_launch(
|
||||||
|
|
||||||
def ycdl_flask_launch_argparse(args):
|
def ycdl_flask_launch_argparse(args):
|
||||||
return ycdl_flask_launch(
|
return ycdl_flask_launch(
|
||||||
create=args.create,
|
|
||||||
localhost_only=args.localhost_only,
|
localhost_only=args.localhost_only,
|
||||||
port=args.port,
|
port=args.port,
|
||||||
refresh_rate=args.refresh_rate,
|
refresh_rate=args.refresh_rate,
|
||||||
|
@ -113,7 +114,6 @@ def main(argv):
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
|
||||||
parser.add_argument('port', nargs='?', type=int, default=5000)
|
parser.add_argument('port', nargs='?', type=int, default=5000)
|
||||||
parser.add_argument('--dont_create', '--dont-create', '--no-create', dest='create', action='store_false', default=True)
|
|
||||||
parser.add_argument('--https', dest='use_https', action='store_true', default=None)
|
parser.add_argument('--https', dest='use_https', action='store_true', default=None)
|
||||||
parser.add_argument('--localhost_only', '--localhost-only', dest='localhost_only', action='store_true')
|
parser.add_argument('--localhost_only', '--localhost-only', dest='localhost_only', action='store_true')
|
||||||
parser.add_argument('--refresh_rate', '--refresh-rate', dest='refresh_rate', type=int, default=None)
|
parser.add_argument('--refresh_rate', '--refresh-rate', dest='refresh_rate', type=int, default=None)
|
||||||
|
|
|
@ -9,7 +9,6 @@ gunicorn ycdl_flask_prod:site --bind "0.0.0.0:PORT" --access-logfile "-"
|
||||||
import werkzeug.middleware.proxy_fix
|
import werkzeug.middleware.proxy_fix
|
||||||
|
|
||||||
import ycdl
|
import ycdl
|
||||||
import youtube_credentials
|
|
||||||
|
|
||||||
from ycdl_flask import backend
|
from ycdl_flask import backend
|
||||||
|
|
||||||
|
@ -19,6 +18,5 @@ site = backend.site
|
||||||
site.debug = False
|
site.debug = False
|
||||||
|
|
||||||
# NOTE: Consider adding a local .json config file.
|
# NOTE: Consider adding a local .json config file.
|
||||||
youtube_core = ycdl.ytapi.Youtube(youtube_credentials.get_youtube_key())
|
backend.common.init_ycdldb()
|
||||||
backend.common.init_ycdldb(youtube_core, create=False)
|
|
||||||
backend.common.start_refresher_thread(86400)
|
backend.common.start_refresher_thread(86400)
|
||||||
|
|
|
@ -1,13 +1,47 @@
|
||||||
'''
|
import argparse
|
||||||
Run `python -i ycdl_repl.py to get an interpreter
|
import code
|
||||||
session with these variables preloaded.
|
import sys
|
||||||
'''
|
|
||||||
import logging
|
from voussoirkit import interactive
|
||||||
logging.basicConfig()
|
from voussoirkit import pipeable
|
||||||
logging.getLogger('ycdl').setLevel(logging.DEBUG)
|
from voussoirkit import vlogging
|
||||||
|
|
||||||
import ycdl
|
import ycdl
|
||||||
import youtube_credentials
|
|
||||||
|
|
||||||
youtube = ycdl.ytapi.Youtube(youtube_credentials.get_youtube_key())
|
def yrepl_argparse(args):
|
||||||
Y = ycdl.ycdldb.YCDLDB(youtube)
|
global Y
|
||||||
|
|
||||||
|
try:
|
||||||
|
Y = ycdl.ycdldb.YCDLDB.closest_ycdldb()
|
||||||
|
except ycdl.exceptions.NoClosestYCDLDB as exc:
|
||||||
|
pipeable.stderr(exc.error_message)
|
||||||
|
pipeable.stderr('Try `ycdl_cli.py init` to create the database.')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.exec_statement:
|
||||||
|
exec(args.exec_statement)
|
||||||
|
Y.commit()
|
||||||
|
else:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
code.interact(banner='', local=dict(globals(), **locals()))
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
if len(Y.savepoints) == 0:
|
||||||
|
break
|
||||||
|
print('You have uncommited changes, are you sure you want to quit?')
|
||||||
|
if interactive.getpermission():
|
||||||
|
break
|
||||||
|
|
||||||
|
@vlogging.main_decorator
|
||||||
|
def main(argv):
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
|
||||||
|
parser.add_argument('--exec', dest='exec_statement', default=None)
|
||||||
|
parser.set_defaults(func=yrepl_argparse)
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
|
|
|
@ -2,7 +2,6 @@ import argparse
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import ycdl
|
import ycdl
|
||||||
import youtube_credentials
|
|
||||||
|
|
||||||
class Migrator:
|
class Migrator:
|
||||||
'''
|
'''
|
||||||
|
@ -320,8 +319,7 @@ def upgrade_all(data_directory):
|
||||||
Given the directory containing a ycdl database, apply all of the
|
Given the directory containing a ycdl database, apply all of the
|
||||||
needed upgrade_x_to_y functions in order.
|
needed upgrade_x_to_y functions in order.
|
||||||
'''
|
'''
|
||||||
youtube = ycdl.ytapi.Youtube(youtube_credentials.get_youtube_key())
|
ycdldb = ycdl.ycdldb.YCDLDB(data_directory, skip_version_check=True)
|
||||||
ycdldb = ycdl.ycdldb.YCDLDB(youtube, data_directory, skip_version_check=True)
|
|
||||||
|
|
||||||
cur = ycdldb.sql.cursor()
|
cur = ycdldb.sql.cursor()
|
||||||
|
|
||||||
|
|
|
@ -4,10 +4,8 @@ import traceback
|
||||||
from voussoirkit import downloady
|
from voussoirkit import downloady
|
||||||
|
|
||||||
import ycdl
|
import ycdl
|
||||||
import youtube_credentials
|
|
||||||
|
|
||||||
youtube_core = ycdl.ytapi.Youtube(youtube_credentials.get_youtube_key())
|
ycdldb = ycdl.ycdldb.YCDLDB()
|
||||||
ycdldb = ycdl.ycdldb.YCDLDB(youtube_core)
|
|
||||||
|
|
||||||
DIRECTORY = '.\\youtube thumbnails'
|
DIRECTORY = '.\\youtube thumbnails'
|
||||||
|
|
||||||
|
|
|
@ -50,7 +50,7 @@ COMMIT;
|
||||||
SQL_COLUMNS = sqlhelpers.extract_table_column_map(DB_INIT)
|
SQL_COLUMNS = sqlhelpers.extract_table_column_map(DB_INIT)
|
||||||
SQL_INDEX = sqlhelpers.reverse_table_column_map(SQL_COLUMNS)
|
SQL_INDEX = sqlhelpers.reverse_table_column_map(SQL_COLUMNS)
|
||||||
|
|
||||||
DEFAULT_DATADIR = '.'
|
DEFAULT_DATADIR = '_ycdl'
|
||||||
DEFAULT_DBNAME = 'ycdl.db'
|
DEFAULT_DBNAME = 'ycdl.db'
|
||||||
DEFAULT_CONFIGNAME = 'ycdl.json'
|
DEFAULT_CONFIGNAME = 'ycdl.json'
|
||||||
|
|
||||||
|
|
|
@ -54,6 +54,11 @@ class NoSuchVideo(YCDLException):
|
||||||
class NoVideos(YCDLException):
|
class NoVideos(YCDLException):
|
||||||
error_message = 'Channel {} has no videos.'
|
error_message = 'Channel {} has no videos.'
|
||||||
|
|
||||||
|
# CHANNEL ERRORS ###################################################################################
|
||||||
|
|
||||||
|
class ChannelRefreshFailed(YCDLException):
|
||||||
|
error_message = 'failed to refresh {channel} ({exc}).'
|
||||||
|
|
||||||
# VIDEO ERRORS #####################################################################################
|
# VIDEO ERRORS #####################################################################################
|
||||||
|
|
||||||
class InvalidVideoState(YCDLException):
|
class InvalidVideoState(YCDLException):
|
||||||
|
@ -64,14 +69,6 @@ class InvalidVideoState(YCDLException):
|
||||||
class RSSAssistFailed(YCDLException):
|
class RSSAssistFailed(YCDLException):
|
||||||
error_message = '{}'
|
error_message = '{}'
|
||||||
|
|
||||||
# SQL ERRORS #######################################################################################
|
|
||||||
|
|
||||||
class BadSQL(YCDLException):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class BadTable(BadSQL):
|
|
||||||
error_message = 'Table "{}" does not exist.'
|
|
||||||
|
|
||||||
# GENERAL ERRORS ###################################################################################
|
# GENERAL ERRORS ###################################################################################
|
||||||
|
|
||||||
class BadDataDirectory(YCDLException):
|
class BadDataDirectory(YCDLException):
|
||||||
|
@ -86,3 +83,10 @@ Please run utilities\\database_upgrader.py "{filepath.absolute_path}"
|
||||||
'''.strip()
|
'''.strip()
|
||||||
class DatabaseOutOfDate(YCDLException):
|
class DatabaseOutOfDate(YCDLException):
|
||||||
error_message = OUTOFDATE
|
error_message = OUTOFDATE
|
||||||
|
|
||||||
|
class NoClosestYCDLDB(YCDLException):
|
||||||
|
'''
|
||||||
|
For calls to YCDLDB.closest_ycdldb where none exists between cwd and
|
||||||
|
drive root.
|
||||||
|
'''
|
||||||
|
error_message = 'There is no YCDLDB in "{}" or its parents.'
|
||||||
|
|
170
ycdl/objects.py
170
ycdl/objects.py
|
@ -1,41 +1,33 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
import googleapiclient.errors
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from voussoirkit import pathclass
|
from voussoirkit import pathclass
|
||||||
from voussoirkit import stringtools
|
from voussoirkit import stringtools
|
||||||
|
from voussoirkit import vlogging
|
||||||
|
from voussoirkit import worms
|
||||||
|
|
||||||
|
log = vlogging.getLogger(__name__)
|
||||||
|
|
||||||
from . import constants
|
from . import constants
|
||||||
from . import exceptions
|
from . import exceptions
|
||||||
from . import ytrss
|
from . import ytrss
|
||||||
|
|
||||||
def normalize_db_row(db_row, table) -> dict:
|
class ObjectBase(worms.Object):
|
||||||
'''
|
|
||||||
Raises KeyError if table is not one of the recognized tables.
|
|
||||||
|
|
||||||
Raises TypeError if db_row is not the right type.
|
|
||||||
'''
|
|
||||||
if isinstance(db_row, dict):
|
|
||||||
return db_row
|
|
||||||
|
|
||||||
if isinstance(db_row, (list, tuple)):
|
|
||||||
return dict(zip(constants.SQL_COLUMNS[table], db_row))
|
|
||||||
|
|
||||||
raise TypeError(f'db_row should be {dict}, {list}, or {tuple}, not {type(db_row)}.')
|
|
||||||
|
|
||||||
class Base:
|
|
||||||
def __init__(self, ycdldb):
|
def __init__(self, ycdldb):
|
||||||
super().__init__()
|
super().__init__(ycdldb)
|
||||||
self.ycdldb = ycdldb
|
self.ycdldb = ycdldb
|
||||||
|
|
||||||
class Channel(Base):
|
class Channel(ObjectBase):
|
||||||
table = 'channels'
|
table = 'channels'
|
||||||
|
no_such_exception = exceptions.NoSuchChannel
|
||||||
|
|
||||||
def __init__(self, ycdldb, db_row):
|
def __init__(self, ycdldb, db_row):
|
||||||
super().__init__(ycdldb)
|
super().__init__(ycdldb)
|
||||||
db_row = normalize_db_row(db_row, self.table)
|
db_row = self.ycdldb.normalize_db_row(db_row, self.table)
|
||||||
|
|
||||||
self.id = db_row['id']
|
self.id = db_row['id']
|
||||||
self.name = db_row['name']
|
self.name = db_row['name'] or self.id
|
||||||
self.uploads_playlist = db_row['uploads_playlist']
|
self.uploads_playlist = db_row['uploads_playlist']
|
||||||
self.download_directory = self.normalize_download_directory(
|
self.download_directory = self.normalize_download_directory(
|
||||||
db_row['download_directory'],
|
db_row['download_directory'],
|
||||||
|
@ -48,6 +40,9 @@ class Channel(Base):
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'Channel:{self.id}'
|
return f'Channel:{self.id}'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Channel:{self.id}:{self.name}'
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize_autorefresh(autorefresh):
|
def normalize_autorefresh(autorefresh):
|
||||||
if isinstance(autorefresh, (str, int)):
|
if isinstance(autorefresh, (str, int)):
|
||||||
|
@ -82,6 +77,20 @@ class Channel(Base):
|
||||||
|
|
||||||
return download_directory
|
return download_directory
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_name(name):
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise TypeError(f'name should be {str}, not {type(name)}.')
|
||||||
|
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize_queuefile_extension(queuefile_extension) -> typing.Optional[str]:
|
def normalize_queuefile_extension(queuefile_extension) -> typing.Optional[str]:
|
||||||
if queuefile_extension is None:
|
if queuefile_extension is None:
|
||||||
|
@ -112,19 +121,17 @@ class Channel(Base):
|
||||||
videos = self.ycdldb.youtube.get_videos(new_ids)
|
videos = self.ycdldb.youtube.get_videos(new_ids)
|
||||||
return videos
|
return videos
|
||||||
|
|
||||||
def delete(self, commit=True):
|
@worms.transaction
|
||||||
self.ycdldb.log.info('Deleting %s.', self)
|
def delete(self):
|
||||||
|
log.info('Deleting %s.', self)
|
||||||
|
|
||||||
self.ycdldb.sql_delete(table='videos', pairs={'author_id': self.id})
|
self.ycdldb.delete(table='videos', pairs={'author_id': self.id})
|
||||||
self.ycdldb.sql_delete(table='channels', pairs={'id': self.id})
|
self.ycdldb.delete(table='channels', pairs={'id': self.id})
|
||||||
|
|
||||||
if commit:
|
|
||||||
self.ycdldb.commit()
|
|
||||||
|
|
||||||
def get_most_recent_video_id(self):
|
def get_most_recent_video_id(self):
|
||||||
query = 'SELECT id FROM videos WHERE author_id == ? ORDER BY published DESC LIMIT 1'
|
query = 'SELECT id FROM videos WHERE author_id == ? ORDER BY published DESC LIMIT 1'
|
||||||
bindings = [self.id]
|
bindings = [self.id]
|
||||||
row = self.ycdldb.sql_select_one(query, bindings)
|
row = self.ycdldb.select_one(query, bindings)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise exceptions.NoVideos(self)
|
raise exceptions.NoVideos(self)
|
||||||
return row[0]
|
return row[0]
|
||||||
|
@ -132,7 +139,7 @@ class Channel(Base):
|
||||||
def has_pending(self):
|
def has_pending(self):
|
||||||
query = 'SELECT 1 FROM videos WHERE author_id == ? AND state == "pending" LIMIT 1'
|
query = 'SELECT 1 FROM videos WHERE author_id == ? AND state == "pending" LIMIT 1'
|
||||||
bindings = [self.id]
|
bindings = [self.id]
|
||||||
return self.ycdldb.sql_select_one(query, bindings) is not None
|
return self.ycdldb.select_one(query, bindings) is not None
|
||||||
|
|
||||||
def jsonify(self):
|
def jsonify(self):
|
||||||
j = {
|
j = {
|
||||||
|
@ -142,8 +149,9 @@ class Channel(Base):
|
||||||
}
|
}
|
||||||
return j
|
return j
|
||||||
|
|
||||||
def refresh(self, *, force=False, rss_assisted=True, commit=True):
|
@worms.transaction
|
||||||
self.ycdldb.log.info('Refreshing %s.', self.id)
|
def refresh(self, *, force=False, rss_assisted=True):
|
||||||
|
log.info('Refreshing %s.', self.id)
|
||||||
|
|
||||||
if force or (not self.uploads_playlist):
|
if force or (not self.uploads_playlist):
|
||||||
self.reset_uploads_playlist_id()
|
self.reset_uploads_playlist_id()
|
||||||
|
@ -154,16 +162,20 @@ class Channel(Base):
|
||||||
try:
|
try:
|
||||||
video_generator = self._rss_assisted_videos()
|
video_generator = self._rss_assisted_videos()
|
||||||
except exceptions.RSSAssistFailed as exc:
|
except exceptions.RSSAssistFailed as exc:
|
||||||
self.ycdldb.log.debug('Caught %s.', exc)
|
log.debug('Caught %s.', exc)
|
||||||
video_generator = self.ycdldb.youtube.get_playlist_videos(self.uploads_playlist)
|
video_generator = self.ycdldb.youtube.get_playlist_videos(self.uploads_playlist)
|
||||||
|
|
||||||
seen_ids = set()
|
seen_ids = set()
|
||||||
for video in video_generator:
|
|
||||||
seen_ids.add(video.id)
|
|
||||||
status = self.ycdldb.ingest_video(video, commit=False)
|
|
||||||
|
|
||||||
if (not status['new']) and (not force):
|
try:
|
||||||
break
|
for video in video_generator:
|
||||||
|
seen_ids.add(video.id)
|
||||||
|
status = self.ycdldb.ingest_video(video)
|
||||||
|
|
||||||
|
if (not status['new']) and (not force):
|
||||||
|
break
|
||||||
|
except googleapiclient.errors.HttpError as exc:
|
||||||
|
raise exceptions.ChannelRefreshFailed(channel=self.id, exc=exc)
|
||||||
|
|
||||||
# Now we will refresh some other IDs that may not have been refreshed
|
# Now we will refresh some other IDs that may not have been refreshed
|
||||||
# by the previous loop.
|
# by the previous loop.
|
||||||
|
@ -186,15 +198,12 @@ class Channel(Base):
|
||||||
refresh_ids.update(v.id for v in videos)
|
refresh_ids.update(v.id for v in videos)
|
||||||
|
|
||||||
if refresh_ids:
|
if refresh_ids:
|
||||||
self.ycdldb.log.debug('Refreshing %d ids separately.', len(refresh_ids))
|
log.debug('Refreshing %d ids separately.', len(refresh_ids))
|
||||||
|
|
||||||
# We call ingest_video instead of insert_video so that
|
# We call ingest_video instead of insert_video so that
|
||||||
# premieres / livestreams which have finished can be automarked.
|
# premieres / livestreams which have finished can be automarked.
|
||||||
for video_id in self.ycdldb.youtube.get_videos(refresh_ids):
|
for video_id in self.ycdldb.youtube.get_videos(refresh_ids):
|
||||||
self.ycdldb.ingest_video(video_id, commit=False)
|
self.ycdldb.ingest_video(video_id)
|
||||||
|
|
||||||
if commit:
|
|
||||||
self.ycdldb.commit()
|
|
||||||
|
|
||||||
def reset_uploads_playlist_id(self):
|
def reset_uploads_playlist_id(self):
|
||||||
'''
|
'''
|
||||||
|
@ -204,7 +213,8 @@ class Channel(Base):
|
||||||
self.set_uploads_playlist_id(self.uploads_playlist)
|
self.set_uploads_playlist_id(self.uploads_playlist)
|
||||||
return self.uploads_playlist
|
return self.uploads_playlist
|
||||||
|
|
||||||
def set_automark(self, state, commit=True):
|
@worms.transaction
|
||||||
|
def set_automark(self, state):
|
||||||
if state not in constants.VIDEO_STATES:
|
if state not in constants.VIDEO_STATES:
|
||||||
raise exceptions.InvalidVideoState(state)
|
raise exceptions.InvalidVideoState(state)
|
||||||
|
|
||||||
|
@ -212,53 +222,56 @@ class Channel(Base):
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'automark': state,
|
'automark': state,
|
||||||
}
|
}
|
||||||
self.ycdldb.sql_update(table='channels', pairs=pairs, where_key='id')
|
self.ycdldb.update(table='channels', pairs=pairs, where_key='id')
|
||||||
self.automark = state
|
self.automark = state
|
||||||
|
|
||||||
if commit:
|
@worms.transaction
|
||||||
self.ycdldb.commit()
|
def set_autorefresh(self, autorefresh):
|
||||||
|
|
||||||
def set_autorefresh(self, autorefresh, commit=True):
|
|
||||||
autorefresh = self.normalize_autorefresh(autorefresh)
|
autorefresh = self.normalize_autorefresh(autorefresh)
|
||||||
|
|
||||||
pairs = {
|
pairs = {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'autorefresh': autorefresh,
|
'autorefresh': autorefresh,
|
||||||
}
|
}
|
||||||
self.ycdldb.sql_update(table='channels', pairs=pairs, where_key='id')
|
self.ycdldb.update(table='channels', pairs=pairs, where_key='id')
|
||||||
self.autorefresh = autorefresh
|
self.autorefresh = autorefresh
|
||||||
|
|
||||||
if commit:
|
@worms.transaction
|
||||||
self.ycdldb.commit()
|
def set_download_directory(self, download_directory):
|
||||||
|
|
||||||
def set_download_directory(self, download_directory, commit=True):
|
|
||||||
download_directory = self.normalize_download_directory(download_directory)
|
download_directory = self.normalize_download_directory(download_directory)
|
||||||
|
|
||||||
pairs = {
|
pairs = {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'download_directory': download_directory.absolute_path if download_directory else None,
|
'download_directory': download_directory.absolute_path if download_directory else None,
|
||||||
}
|
}
|
||||||
self.ycdldb.sql_update(table='channels', pairs=pairs, where_key='id')
|
self.ycdldb.update(table='channels', pairs=pairs, where_key='id')
|
||||||
self.download_directory = download_directory
|
self.download_directory = download_directory
|
||||||
|
|
||||||
if commit:
|
@worms.transaction
|
||||||
self.ycdldb.commit()
|
def set_name(self, name):
|
||||||
|
name = self.normalize_name(name)
|
||||||
|
|
||||||
def set_queuefile_extension(self, queuefile_extension, commit=True):
|
pairs = {
|
||||||
|
'id': self.id,
|
||||||
|
'name': name,
|
||||||
|
}
|
||||||
|
self.ycdldb.update(table='channels', pairs=pairs, where_key='id')
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
@worms.transaction
|
||||||
|
def set_queuefile_extension(self, queuefile_extension):
|
||||||
queuefile_extension = self.normalize_queuefile_extension(queuefile_extension)
|
queuefile_extension = self.normalize_queuefile_extension(queuefile_extension)
|
||||||
|
|
||||||
pairs = {
|
pairs = {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'queuefile_extension': queuefile_extension,
|
'queuefile_extension': queuefile_extension,
|
||||||
}
|
}
|
||||||
self.ycdldb.sql_update(table='channels', pairs=pairs, where_key='id')
|
self.ycdldb.update(table='channels', pairs=pairs, where_key='id')
|
||||||
self.queuefile_extension = queuefile_extension
|
self.queuefile_extension = queuefile_extension
|
||||||
|
|
||||||
if commit:
|
@worms.transaction
|
||||||
self.ycdldb.commit()
|
def set_uploads_playlist_id(self, playlist_id):
|
||||||
|
log.debug('Setting %s upload playlist to %s.', self.id, playlist_id)
|
||||||
def set_uploads_playlist_id(self, playlist_id, commit=True):
|
|
||||||
self.ycdldb.log.debug('Setting %s upload playlist to %s.', self.id, playlist_id)
|
|
||||||
if not isinstance(playlist_id, str):
|
if not isinstance(playlist_id, str):
|
||||||
raise TypeError(f'Playlist id must be a string, not {type(playlist_id)}.')
|
raise TypeError(f'Playlist id must be a string, not {type(playlist_id)}.')
|
||||||
|
|
||||||
|
@ -266,18 +279,16 @@ class Channel(Base):
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'uploads_playlist': playlist_id,
|
'uploads_playlist': playlist_id,
|
||||||
}
|
}
|
||||||
self.ycdldb.sql_update(table='channels', pairs=pairs, where_key='id')
|
self.ycdldb.update(table='channels', pairs=pairs, where_key='id')
|
||||||
self.uploads_playlist = playlist_id
|
self.uploads_playlist = playlist_id
|
||||||
|
|
||||||
if commit:
|
class Video(ObjectBase):
|
||||||
self.ycdldb.commit()
|
|
||||||
|
|
||||||
class Video(Base):
|
|
||||||
table = 'videos'
|
table = 'videos'
|
||||||
|
no_such_exception = exceptions.NoSuchVideo
|
||||||
|
|
||||||
def __init__(self, ycdldb, db_row):
|
def __init__(self, ycdldb, db_row):
|
||||||
super().__init__(ycdldb)
|
super().__init__(ycdldb)
|
||||||
db_row = normalize_db_row(db_row, self.table)
|
db_row = self.ycdldb.normalize_db_row(db_row, self.table)
|
||||||
|
|
||||||
self.id = db_row['id']
|
self.id = db_row['id']
|
||||||
self.published = db_row['published']
|
self.published = db_row['published']
|
||||||
|
@ -300,13 +311,11 @@ class Video(Base):
|
||||||
except exceptions.NoSuchChannel:
|
except exceptions.NoSuchChannel:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def delete(self, commit=True):
|
@worms.transaction
|
||||||
self.ycdldb.log.info('Deleting %s.', self)
|
def delete(self):
|
||||||
|
log.info('Deleting %s.', self)
|
||||||
|
|
||||||
self.ycdldb.sql_delete(table='videos', pairs={'id': self.id})
|
self.ycdldb.delete(table='videos', pairs={'id': self.id})
|
||||||
|
|
||||||
if commit:
|
|
||||||
self.ycdldb.commit()
|
|
||||||
|
|
||||||
def jsonify(self):
|
def jsonify(self):
|
||||||
j = {
|
j = {
|
||||||
|
@ -322,24 +331,25 @@ class Video(Base):
|
||||||
}
|
}
|
||||||
return j
|
return j
|
||||||
|
|
||||||
def mark_state(self, state, commit=True):
|
@worms.transaction
|
||||||
|
def mark_state(self, state):
|
||||||
'''
|
'''
|
||||||
Mark the video as ignored, pending, or downloaded.
|
Mark the video as ignored, pending, or downloaded.
|
||||||
|
|
||||||
|
Note: Marking as downloaded will not create the queue file, this only
|
||||||
|
updates the database. See yclddb.download_video.
|
||||||
'''
|
'''
|
||||||
if state not in constants.VIDEO_STATES:
|
if state not in constants.VIDEO_STATES:
|
||||||
raise exceptions.InvalidVideoState(state)
|
raise exceptions.InvalidVideoState(state)
|
||||||
|
|
||||||
self.ycdldb.log.info('Marking %s as %s.', self, state)
|
log.info('Marking %s as %s.', self, state)
|
||||||
|
|
||||||
pairs = {
|
pairs = {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'state': state,
|
'state': state,
|
||||||
}
|
}
|
||||||
self.state = state
|
self.state = state
|
||||||
self.ycdldb.sql_update(table='videos', pairs=pairs, where_key='id')
|
self.ycdldb.update(table='videos', pairs=pairs, where_key='id')
|
||||||
|
|
||||||
if commit:
|
|
||||||
self.ycdldb.commit()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def published_string(self):
|
def published_string(self):
|
||||||
|
|
420
ycdl/ycdldb.py
420
ycdl/ycdldb.py
|
@ -1,176 +1,92 @@
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
|
from voussoirkit import cacheclass
|
||||||
|
from voussoirkit import configlayers
|
||||||
|
from voussoirkit import pathclass
|
||||||
|
from voussoirkit import vlogging
|
||||||
|
from voussoirkit import worms
|
||||||
|
|
||||||
|
log = vlogging.getLogger(__name__)
|
||||||
|
|
||||||
from . import constants
|
from . import constants
|
||||||
from . import exceptions
|
from . import exceptions
|
||||||
from . import objects
|
from . import objects
|
||||||
from . import ytapi
|
from . import ytapi
|
||||||
from . import ytrss
|
from . import ytrss
|
||||||
|
|
||||||
from voussoirkit import cacheclass
|
import youtube_credentials
|
||||||
from voussoirkit import configlayers
|
|
||||||
from voussoirkit import pathclass
|
|
||||||
from voussoirkit import sqlhelpers
|
|
||||||
from voussoirkit import vlogging
|
|
||||||
|
|
||||||
class YCDLDBCacheManagerMixin:
|
|
||||||
_THING_CLASSES = {
|
|
||||||
'channel':
|
|
||||||
{
|
|
||||||
'class': objects.Channel,
|
|
||||||
'exception': exceptions.NoSuchChannel,
|
|
||||||
},
|
|
||||||
'video':
|
|
||||||
{
|
|
||||||
'class': objects.Video,
|
|
||||||
'exception': exceptions.NoSuchVideo,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def get_cached_instance(self, thing_type, db_row):
|
|
||||||
'''
|
|
||||||
Check if there is already an instance in the cache and return that.
|
|
||||||
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.
|
|
||||||
'''
|
|
||||||
thing_map = self._THING_CLASSES[thing_type]
|
|
||||||
|
|
||||||
thing_class = thing_map['class']
|
|
||||||
thing_table = thing_class.table
|
|
||||||
thing_cache = self.caches[thing_type]
|
|
||||||
|
|
||||||
if isinstance(db_row, dict):
|
|
||||||
thing_id = db_row['id']
|
|
||||||
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
|
|
||||||
|
|
||||||
def get_things(self, thing_type):
|
|
||||||
'''
|
|
||||||
Yield things, unfiltered, in whatever order they appear in the database.
|
|
||||||
'''
|
|
||||||
thing_map = self._THING_CLASSES[thing_type]
|
|
||||||
table = thing_map['class'].table
|
|
||||||
query = f'SELECT * FROM {table}'
|
|
||||||
|
|
||||||
things = self.sql_select(query)
|
|
||||||
for thing_row in things:
|
|
||||||
thing = self.get_cached_instance(thing_type, thing_row)
|
|
||||||
yield thing
|
|
||||||
|
|
||||||
def get_things_by_sql(self, thing_type, query, bindings=None):
|
|
||||||
'''
|
|
||||||
Use an arbitrary SQL query to select things from the database.
|
|
||||||
Your query select *, all the columns of the thing's table.
|
|
||||||
'''
|
|
||||||
thing_rows = self.sql_select(query, bindings)
|
|
||||||
for thing_row in thing_rows:
|
|
||||||
yield self.get_cached_instance(thing_type, thing_row)
|
|
||||||
|
|
||||||
class YCDLDBChannelMixin:
|
class YCDLDBChannelMixin:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
@worms.transaction
|
||||||
def add_channel(
|
def add_channel(
|
||||||
self,
|
self,
|
||||||
channel_id,
|
channel_id,
|
||||||
*,
|
*,
|
||||||
commit=True,
|
automark='pending',
|
||||||
download_directory=None,
|
download_directory=None,
|
||||||
queuefile_extension=None,
|
queuefile_extension=None,
|
||||||
get_videos=False,
|
get_videos=False,
|
||||||
name=None,
|
name=None,
|
||||||
):
|
):
|
||||||
|
'''
|
||||||
|
Raises exceptions.InvalidVideoState if automark is not
|
||||||
|
one of constants.VIDEO_STATES.
|
||||||
|
Raises TypeError if name is not a string.
|
||||||
|
Raises TypeError if queuefile_extension is not a string.
|
||||||
|
Raises pathclass.NotDirectory is download_directory is not an existing
|
||||||
|
directory (via objects.Channel.normalize_download_directory).
|
||||||
|
'''
|
||||||
try:
|
try:
|
||||||
return self.get_channel(channel_id)
|
return self.get_channel(channel_id)
|
||||||
except exceptions.NoSuchChannel:
|
except exceptions.NoSuchChannel:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if automark not in constants.VIDEO_STATES:
|
||||||
|
raise exceptions.InvalidVideoState(automark)
|
||||||
|
|
||||||
|
name = objects.Channel.normalize_name(name)
|
||||||
if name is None:
|
if name is None:
|
||||||
name = self.youtube.get_user_name(channel_id)
|
name = self.youtube.get_user_name(channel_id)
|
||||||
|
|
||||||
download_directory = objects.Channel.normalize_download_directory(download_directory)
|
download_directory = objects.Channel.normalize_download_directory(download_directory)
|
||||||
|
download_directory = download_directory.absolute_path if download_directory else None
|
||||||
queuefile_extension = objects.Channel.normalize_queuefile_extension(queuefile_extension)
|
queuefile_extension = objects.Channel.normalize_queuefile_extension(queuefile_extension)
|
||||||
|
|
||||||
self.log.info('Adding channel %s %s', channel_id, name)
|
log.info('Adding channel %s %s', channel_id, name)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'id': channel_id,
|
'id': channel_id,
|
||||||
'name': name,
|
'name': name,
|
||||||
'uploads_playlist': self.youtube.get_user_uploads_playlist_id(channel_id),
|
'uploads_playlist': self.youtube.get_user_uploads_playlist_id(channel_id),
|
||||||
'download_directory': download_directory.absolute_path if download_directory else None,
|
'download_directory': download_directory,
|
||||||
'queuefile_extension': queuefile_extension,
|
'queuefile_extension': queuefile_extension,
|
||||||
'automark': 'pending',
|
'automark': automark,
|
||||||
'autorefresh': True,
|
'autorefresh': True,
|
||||||
}
|
}
|
||||||
self.sql_insert(table='channels', data=data)
|
self.insert(table='channels', data=data)
|
||||||
|
|
||||||
channel = objects.Channel(self, data)
|
channel = objects.Channel(self, data)
|
||||||
self.caches['channel'][channel_id] = channel
|
|
||||||
|
|
||||||
if get_videos:
|
if get_videos:
|
||||||
channel.refresh(commit=False)
|
channel.refresh()
|
||||||
|
|
||||||
if commit:
|
|
||||||
self.commit()
|
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
def get_channel(self, channel_id):
|
def get_channel(self, channel_id):
|
||||||
return self.get_thing_by_id('channel', channel_id)
|
return self.get_object_by_id(objects.Channel, channel_id)
|
||||||
|
|
||||||
def get_channels(self):
|
def get_channels(self):
|
||||||
return self.get_things(thing_type='channel')
|
return self.get_objects(objects.Channel)
|
||||||
|
|
||||||
def get_channels_by_sql(self, query, bindings=None):
|
def get_channels_by_sql(self, query, bindings=None):
|
||||||
return self.get_things_by_sql('channel', query, bindings)
|
return self.get_objects_by_sql(objects.Channel, query, bindings)
|
||||||
|
|
||||||
def _rss_assisted_refresh(self, channels, skip_failures=False, commit=True):
|
@worms.transaction
|
||||||
|
def _rss_assisted_refresh(self, channels, skip_failures=False):
|
||||||
'''
|
'''
|
||||||
Youtube provides RSS feeds for every channel. These feeds do not
|
Youtube provides RSS feeds for every channel. These feeds do not
|
||||||
require the API token and seem to have generous ratelimits, or
|
require the API token and seem to have generous ratelimits, or
|
||||||
|
@ -200,6 +116,7 @@ class YCDLDBChannelMixin:
|
||||||
channel.refresh(rss_assisted=False)
|
channel.refresh(rss_assisted=False)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if skip_failures:
|
if skip_failures:
|
||||||
|
log.warning(exc)
|
||||||
excs.append(exc)
|
excs.append(exc)
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
@ -210,7 +127,7 @@ class YCDLDBChannelMixin:
|
||||||
new_ids = ytrss.get_user_videos_since(channel.id, most_recent_video)
|
new_ids = ytrss.get_user_videos_since(channel.id, most_recent_video)
|
||||||
yield from new_ids
|
yield from new_ids
|
||||||
except (exceptions.NoVideos, exceptions.RSSAssistFailed) as exc:
|
except (exceptions.NoVideos, exceptions.RSSAssistFailed) as exc:
|
||||||
self.log.debug(
|
log.debug(
|
||||||
'RSS assist for %s failed "%s", using traditional refresh.',
|
'RSS assist for %s failed "%s", using traditional refresh.',
|
||||||
channel.id,
|
channel.id,
|
||||||
exc.error_message
|
exc.error_message
|
||||||
|
@ -219,115 +136,43 @@ class YCDLDBChannelMixin:
|
||||||
|
|
||||||
new_ids = (id for channel in channels for id in assisted(channel))
|
new_ids = (id for channel in channels for id in assisted(channel))
|
||||||
for video in self.youtube.get_videos(new_ids):
|
for video in self.youtube.get_videos(new_ids):
|
||||||
self.ingest_video(video, commit=False)
|
self.ingest_video(video)
|
||||||
|
|
||||||
if commit:
|
|
||||||
self.commit()
|
|
||||||
|
|
||||||
return excs
|
return excs
|
||||||
|
|
||||||
|
@worms.transaction
|
||||||
def refresh_all_channels(
|
def refresh_all_channels(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
force=False,
|
force=False,
|
||||||
rss_assisted=True,
|
rss_assisted=True,
|
||||||
skip_failures=False,
|
skip_failures=False,
|
||||||
commit=True,
|
|
||||||
):
|
):
|
||||||
self.log.info('Refreshing all channels.')
|
log.info('Refreshing all channels.')
|
||||||
|
|
||||||
channels = self.get_channels_by_sql('SELECT * FROM channels WHERE autorefresh == 1')
|
channels = self.get_channels_by_sql('SELECT * FROM channels WHERE autorefresh == 1')
|
||||||
|
|
||||||
if rss_assisted and not force:
|
if rss_assisted and not force:
|
||||||
return self._rss_assisted_refresh(channels, skip_failures=skip_failures, commit=commit)
|
return self._rss_assisted_refresh(channels, skip_failures=skip_failures)
|
||||||
|
|
||||||
excs = []
|
excs = []
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
try:
|
try:
|
||||||
channel.refresh(force=force, commit=commit)
|
channel.refresh(force=force)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if skip_failures:
|
if skip_failures:
|
||||||
self.log.warning(exc)
|
log.warning(exc)
|
||||||
excs.append(exc)
|
excs.append(exc)
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
if commit:
|
|
||||||
self.commit()
|
|
||||||
|
|
||||||
return excs
|
return excs
|
||||||
|
|
||||||
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.loud('%s %s', 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:
|
class YCDLDBVideoMixin:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def download_video(self, video, commit=True, force=False):
|
@worms.transaction
|
||||||
|
def download_video(self, video, force=False):
|
||||||
'''
|
'''
|
||||||
Create the queuefile within the channel's associated directory, or
|
Create the queuefile within the channel's associated directory, or
|
||||||
the default directory from the config file.
|
the default directory from the config file.
|
||||||
|
@ -342,7 +187,7 @@ class YCDLDBVideoMixin:
|
||||||
raise TypeError(video)
|
raise TypeError(video)
|
||||||
|
|
||||||
if video.state != 'pending' and not force:
|
if video.state != 'pending' and not force:
|
||||||
self.log.debug('%s does not need to be downloaded.', video.id)
|
log.debug('%s does not need to be downloaded.', video.id)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -356,18 +201,17 @@ class YCDLDBVideoMixin:
|
||||||
download_directory = pathclass.Path(download_directory)
|
download_directory = pathclass.Path(download_directory)
|
||||||
queuefile = download_directory.with_child(video.id).replace_extension(extension)
|
queuefile = download_directory.with_child(video.id).replace_extension(extension)
|
||||||
|
|
||||||
self.log.info('Creating %s.', queuefile.absolute_path)
|
def create_queuefile():
|
||||||
|
log.info('Creating %s.', queuefile.absolute_path)
|
||||||
|
|
||||||
download_directory.makedirs(exist_ok=True)
|
download_directory.makedirs(exist_ok=True)
|
||||||
queuefile.touch()
|
queuefile.touch()
|
||||||
|
|
||||||
video.mark_state('downloaded', commit=False)
|
self.on_commit_queue.append({'action': create_queuefile})
|
||||||
|
video.mark_state('downloaded')
|
||||||
if commit:
|
|
||||||
self.commit()
|
|
||||||
|
|
||||||
def get_video(self, video_id):
|
def get_video(self, video_id):
|
||||||
return self.get_thing_by_id('video', video_id)
|
return self.get_object_by_id(objects.Video, video_id)
|
||||||
|
|
||||||
def get_videos(self, channel_id=None, *, state=None, orderby=None):
|
def get_videos(self, channel_id=None, *, state=None, orderby=None):
|
||||||
wheres = []
|
wheres = []
|
||||||
|
@ -403,32 +247,31 @@ class YCDLDBVideoMixin:
|
||||||
|
|
||||||
query = 'SELECT * FROM videos' + wheres + orderbys
|
query = 'SELECT * FROM videos' + wheres + orderbys
|
||||||
|
|
||||||
self.log.debug('%s %s', query, bindings)
|
log.debug('%s %s', query, bindings)
|
||||||
explain = self.sql_execute('EXPLAIN QUERY PLAN ' + query, bindings)
|
explain = self.execute('EXPLAIN QUERY PLAN ' + query, bindings)
|
||||||
self.log.debug('\n'.join(str(x) for x in explain.fetchall()))
|
log.debug('\n'.join(str(x) for x in explain.fetchall()))
|
||||||
|
|
||||||
rows = self.sql_select(query, bindings)
|
rows = self.select(query, bindings)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
yield self.get_cached_instance('video', row)
|
yield self.get_cached_instance(objects.Video, row)
|
||||||
|
|
||||||
def get_videos_by_sql(self, query, bindings=None):
|
def get_videos_by_sql(self, query, bindings=None):
|
||||||
return self.get_things_by_sql('video', query, bindings)
|
return self.get_objects_by_sql(objects.Video, query, bindings)
|
||||||
|
|
||||||
def insert_playlist(self, playlist_id, commit=True):
|
@worms.transaction
|
||||||
|
def insert_playlist(self, playlist_id):
|
||||||
video_generator = self.youtube.get_playlist_videos(playlist_id)
|
video_generator = self.youtube.get_playlist_videos(playlist_id)
|
||||||
results = [self.insert_video(video, commit=False) for video in video_generator]
|
results = [self.insert_video(video) for video in video_generator]
|
||||||
|
|
||||||
if commit:
|
|
||||||
self.commit()
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def ingest_video(self, video, commit=True):
|
@worms.transaction
|
||||||
|
def ingest_video(self, video):
|
||||||
'''
|
'''
|
||||||
Call `insert_video`, and additionally use the channel's automark to
|
Call `insert_video`, and additionally use the channel's automark to
|
||||||
mark this video's state.
|
mark this video's state.
|
||||||
'''
|
'''
|
||||||
status = self.insert_video(video, commit=False)
|
status = self.insert_video(video)
|
||||||
|
|
||||||
if not status['new']:
|
if not status['new']:
|
||||||
return status
|
return status
|
||||||
|
@ -444,28 +287,26 @@ class YCDLDBVideoMixin:
|
||||||
|
|
||||||
if author.automark == 'downloaded':
|
if author.automark == 'downloaded':
|
||||||
if video.live_broadcast is not None:
|
if video.live_broadcast is not None:
|
||||||
self.log.debug(
|
log.debug(
|
||||||
'Not downloading %s because live_broadcast=%s.',
|
'Not downloading %s because live_broadcast=%s.',
|
||||||
video.id,
|
video.id,
|
||||||
video.live_broadcast,
|
video.live_broadcast,
|
||||||
)
|
)
|
||||||
return status
|
return status
|
||||||
# download_video contains a call to mark_state.
|
# download_video contains a call to mark_state.
|
||||||
self.download_video(video.id, commit=False)
|
self.download_video(video.id)
|
||||||
else:
|
else:
|
||||||
video.mark_state(author.automark, commit=False)
|
video.mark_state(author.automark)
|
||||||
|
|
||||||
if commit:
|
|
||||||
self.commit()
|
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|
||||||
def insert_video(self, video, *, add_channel=True, commit=True):
|
@worms.transaction
|
||||||
|
def insert_video(self, video, *, add_channel=True):
|
||||||
if not isinstance(video, ytapi.Video):
|
if not isinstance(video, ytapi.Video):
|
||||||
video = self.youtube.get_video(video)
|
video = self.youtube.get_video(video)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
existing = self.get_video(video.id)
|
existing = self.get_video(video.id)
|
||||||
|
@ -490,19 +331,16 @@ class YCDLDBVideoMixin:
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
self.log.loud('Updating Video %s.', video.id)
|
log.loud('Updating Video %s.', video.id)
|
||||||
self.sql_update(table='videos', pairs=data, where_key='id')
|
self.update(table='videos', pairs=data, where_key='id')
|
||||||
else:
|
else:
|
||||||
self.log.loud('Inserting Video %s.', video.id)
|
log.loud('Inserting Video %s.', video.id)
|
||||||
self.sql_insert(table='videos', data=data)
|
self.insert(table='videos', data=data)
|
||||||
|
|
||||||
# Override the cached copy with the new copy so that the cache contains
|
# Override the cached copy with the new copy so that the cache contains
|
||||||
# updated information (view counts etc.).
|
# updated information (view counts etc.).
|
||||||
video = objects.Video(self, data)
|
video = objects.Video(self, data)
|
||||||
self.caches['video'][video.id] = video
|
self.caches[objects.Video][video.id] = video
|
||||||
|
|
||||||
if commit:
|
|
||||||
self.commit()
|
|
||||||
|
|
||||||
# For the benefit of ingest_video, which will only apply the channel's
|
# For the benefit of ingest_video, which will only apply the channel's
|
||||||
# automark to newly released videos, let's consider the video to be
|
# automark to newly released videos, let's consider the video to be
|
||||||
|
@ -516,20 +354,21 @@ class YCDLDBVideoMixin:
|
||||||
return {'new': is_new, 'video': video}
|
return {'new': is_new, 'video': video}
|
||||||
|
|
||||||
class YCDLDB(
|
class YCDLDB(
|
||||||
YCDLDBCacheManagerMixin,
|
|
||||||
YCDLDBChannelMixin,
|
YCDLDBChannelMixin,
|
||||||
YCDLDBVideoMixin,
|
YCDLDBVideoMixin,
|
||||||
YCDLSQLMixin,
|
worms.DatabaseWithCaching,
|
||||||
):
|
):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
youtube,
|
youtube=None,
|
||||||
*,
|
*,
|
||||||
create=True,
|
create=False,
|
||||||
data_directory=None,
|
data_directory=None,
|
||||||
skip_version_check=False,
|
skip_version_check=False,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
if youtube is None:
|
||||||
|
youtube = ytapi.Youtube(youtube_credentials.get_youtube_key())
|
||||||
self.youtube = youtube
|
self.youtube = youtube
|
||||||
|
|
||||||
# DATA DIR PREP
|
# DATA DIR PREP
|
||||||
|
@ -541,10 +380,41 @@ class YCDLDB(
|
||||||
if self.data_directory.exists and not self.data_directory.is_dir:
|
if self.data_directory.exists and not self.data_directory.is_dir:
|
||||||
raise exceptions.BadDataDirectory(self.data_directory.absolute_path)
|
raise exceptions.BadDataDirectory(self.data_directory.absolute_path)
|
||||||
|
|
||||||
# LOGGING
|
|
||||||
self.log = vlogging.getLogger(f'{__name__}:{self.data_directory.absolute_path}')
|
|
||||||
|
|
||||||
# DATABASE
|
# DATABASE
|
||||||
|
self._init_sql(create=create, skip_version_check=skip_version_check)
|
||||||
|
|
||||||
|
# CONFIG
|
||||||
|
self.config_filepath = self.data_directory.with_child(constants.DEFAULT_CONFIGNAME)
|
||||||
|
self.load_config()
|
||||||
|
|
||||||
|
# WORMS
|
||||||
|
self._init_column_index()
|
||||||
|
self._init_caches()
|
||||||
|
|
||||||
|
def _check_version(self):
|
||||||
|
'''
|
||||||
|
Compare database's user_version against constants.DATABASE_VERSION,
|
||||||
|
raising exceptions.DatabaseOutOfDate if not correct.
|
||||||
|
'''
|
||||||
|
existing = self.execute('PRAGMA user_version').fetchone()[0]
|
||||||
|
if existing != constants.DATABASE_VERSION:
|
||||||
|
raise exceptions.DatabaseOutOfDate(
|
||||||
|
existing=existing,
|
||||||
|
new=constants.DATABASE_VERSION,
|
||||||
|
filepath=self.data_directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _init_caches(self):
|
||||||
|
self.caches = {
|
||||||
|
objects.Channel: cacheclass.Cache(maxlen=20_000),
|
||||||
|
objects.Video: cacheclass.Cache(maxlen=50_000),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _init_column_index(self):
|
||||||
|
self.COLUMNS = constants.SQL_COLUMNS
|
||||||
|
self.COLUMN_INDEX = constants.SQL_INDEX
|
||||||
|
|
||||||
|
def _init_sql(self, create, skip_version_check):
|
||||||
self.database_filepath = self.data_directory.with_child(constants.DEFAULT_DBNAME)
|
self.database_filepath = self.data_directory.with_child(constants.DEFAULT_DBNAME)
|
||||||
existing_database = self.database_filepath.exists
|
existing_database = self.database_filepath.exists
|
||||||
if not existing_database and not create:
|
if not existing_database and not create:
|
||||||
|
@ -561,38 +431,46 @@ class YCDLDB(
|
||||||
else:
|
else:
|
||||||
self._first_time_setup()
|
self._first_time_setup()
|
||||||
|
|
||||||
# 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):
|
|
||||||
'''
|
|
||||||
Compare database's user_version against constants.DATABASE_VERSION,
|
|
||||||
raising exceptions.DatabaseOutOfDate if not correct.
|
|
||||||
'''
|
|
||||||
existing = self.sql.execute('PRAGMA user_version').fetchone()[0]
|
|
||||||
if existing != constants.DATABASE_VERSION:
|
|
||||||
raise exceptions.DatabaseOutOfDate(
|
|
||||||
existing=existing,
|
|
||||||
new=constants.DATABASE_VERSION,
|
|
||||||
filepath=self.data_directory,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _first_time_setup(self):
|
def _first_time_setup(self):
|
||||||
self.log.info('Running first-time database setup.')
|
log.info('Running first-time database setup.')
|
||||||
self.sql.executescript(constants.DB_INIT)
|
self.executescript(constants.DB_INIT)
|
||||||
self.commit()
|
self.commit()
|
||||||
|
|
||||||
def _load_pragmas(self):
|
def _load_pragmas(self):
|
||||||
self.log.debug('Reloading pragmas.')
|
log.debug('Reloading pragmas.')
|
||||||
self.sql.executescript(constants.DB_PRAGMAS)
|
self.executescript(constants.DB_PRAGMAS)
|
||||||
self.commit()
|
self.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def closest_ycdldb(cls, youtube=None, path='.', *args, **kwargs):
|
||||||
|
'''
|
||||||
|
Starting from the given path and climbing upwards towards the filesystem
|
||||||
|
root, look for an existing YCDL data directory and return the
|
||||||
|
YCDLDB object. If none exists, raise exceptions.NoClosestYCDLDB.
|
||||||
|
'''
|
||||||
|
path = pathclass.Path(path)
|
||||||
|
starting = path
|
||||||
|
|
||||||
|
while True:
|
||||||
|
possible = path.with_child(constants.DEFAULT_DATADIR)
|
||||||
|
if possible.is_dir:
|
||||||
|
break
|
||||||
|
parent = path.parent
|
||||||
|
if path == parent:
|
||||||
|
raise exceptions.NoClosestYCDLDB(starting.absolute_path)
|
||||||
|
path = parent
|
||||||
|
|
||||||
|
path = possible
|
||||||
|
ycdldb = cls(
|
||||||
|
youtube=youtube,
|
||||||
|
data_directory=path,
|
||||||
|
create=False,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
log.debug('Found closest YCDLDB at %s.', path)
|
||||||
|
return ycdldb
|
||||||
|
|
||||||
def get_all_states(self):
|
def get_all_states(self):
|
||||||
'''
|
'''
|
||||||
Get a list of all the different states that are currently in use in
|
Get a list of all the different states that are currently in use in
|
||||||
|
@ -602,7 +480,7 @@ class YCDLDB(
|
||||||
# arbitrarily many states for user-defined purposes, but I kind of went
|
# 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.
|
# back on that so I'm not sure if it will be useful.
|
||||||
query = 'SELECT DISTINCT state FROM videos'
|
query = 'SELECT DISTINCT state FROM videos'
|
||||||
states = self.sql_select(query)
|
states = self.select(query)
|
||||||
states = [row[0] for row in states]
|
states = [row[0] for row in states]
|
||||||
return sorted(states)
|
return sorted(states)
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import apiclient.discovery
|
import googleapiclient.discovery
|
||||||
import isodate
|
import isodate
|
||||||
|
|
||||||
from voussoirkit import gentools
|
from voussoirkit import gentools
|
||||||
|
@ -53,7 +53,7 @@ class Video:
|
||||||
|
|
||||||
class Youtube:
|
class Youtube:
|
||||||
def __init__(self, key):
|
def __init__(self, key):
|
||||||
self.youtube = apiclient.discovery.build(
|
self.youtube = googleapiclient.discovery.build(
|
||||||
cache_discovery=False,
|
cache_discovery=False,
|
||||||
developerKey=key,
|
developerKey=key,
|
||||||
serviceName='youtube',
|
serviceName='youtube',
|
||||||
|
|
Loading…
Reference in a new issue