diff --git a/README.md b/README.md index 4109cf1..05d0784 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,56 @@ Youtube Channel Downloader ========================== -You are responsible for your own `bot.py` file, containing a variable `YOUTUBE_KEY`. \ No newline at end of file +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. diff --git a/frontends/ycdl_flask/backend/endpoints.py b/frontends/ycdl_flask/backend/endpoints.py index 59a7ea6..0844e1b 100644 --- a/frontends/ycdl_flask/backend/endpoints.py +++ b/frontends/ycdl_flask/backend/endpoints.py @@ -21,8 +21,6 @@ def favicon(): @site.route('/channels') def 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) @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() 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) if limit is not None: @@ -66,10 +64,10 @@ def get_channel(channel_id=None, download_filter=None): pass for video in videos: - published = video['published'] + published = video.published published = datetime.datetime.utcfromtimestamp(published) published = published.strftime('%Y %m %d') - video['_published_str'] = published + video._published_str = published all_states = common.ycdldb.get_all_states() @@ -91,7 +89,8 @@ def post_mark_video_state(): try: video_ids = video_ids.split(',') 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() except ycdl.exceptions.NoSuchVideo: @@ -129,8 +128,8 @@ def post_refresh_channel(): force = request.form.get('force', False) force = ycdl.helpers.truthystring(force) - common.ycdldb.add_channel(channel_id, commit=False) - common.ycdldb.refresh_channel(channel_id, force=force) + channel = common.ycdldb.add_channel(channel_id, commit=False) + channel.refresh(force=force) return jsonify.make_json_response({}) @site.route('/start_download', methods=['POST']) diff --git a/frontends/ycdl_flask/static/common.css b/frontends/ycdl_flask/static/common.css index 75f0737..495c3e6 100644 --- a/frontends/ycdl_flask/static/common.css +++ b/frontends/ycdl_flask/static/common.css @@ -26,7 +26,7 @@ body } .hidden { - display: none; + display: none !important; } #content_body { diff --git a/frontends/ycdl_flask/static/common.js b/frontends/ycdl_flask/static/common.js index a4797c8..df98e9f 100644 --- a/frontends/ycdl_flask/static/common.js +++ b/frontends/ycdl_flask/static/common.js @@ -1,3 +1,6 @@ +var common = {}; + +common.post_example = function post_example(key, value, callback) { var url = "/postexample"; @@ -6,11 +9,13 @@ function post_example(key, value, callback) return post(url, data, callback); } +common.null_callback = function null_callback() { return; } +common.post = function post(url, data, callback) { var request = new XMLHttpRequest(); @@ -32,6 +37,7 @@ function post(url, data, callback) request.send(data); } +common.bind_box_to_button = function bind_box_to_button(box, button) { 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) { //console.log(event.keyCode); @@ -81,3 +88,25 @@ function entry_with_history_hook(box, button) 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); diff --git a/frontends/ycdl_flask/templates/channel.html b/frontends/ycdl_flask/templates/channel.html index 97b3284..4a7db6d 100644 --- a/frontends/ycdl_flask/templates/channel.html +++ b/frontends/ycdl_flask/templates/channel.html @@ -2,7 +2,7 @@
{% import "header.html" as header %} -