base upload of offline files
This commit is contained in:
parent
8f48982077
commit
645beccad3
1
LICENSE
1
LICENSE
|
@ -1,4 +1,5 @@
|
|||
I haven't chosen a definitive licence yet. So for now this software is licenced under the Unlicence licence.
|
||||
It may change at any time however!
|
||||
|
||||
_______________________________________________________________________
|
||||
|
||||
|
|
10
README.md
10
README.md
|
@ -3,11 +3,16 @@
|
|||
This project will be awesome, only if I invest enough time. This software will replace my
|
||||
current cronjob yt-dlp archive service.
|
||||
|
||||
Partially inspired by [hobune](https://github.com/rebane2001/hobune). While that project is amazing by it's own, it's just not scaleable.
|
||||
|
||||
## The idea
|
||||
The new setup will either be fully running in flask, including the task that checks the
|
||||
youtube channels every x hours. Or Flask will be used as the gui frontend, and a seperate
|
||||
script will do the channel archiving. I have not desided yet.
|
||||
|
||||
What currently works is that the gui frontend calls to a seperate database while a cronjob
|
||||
handles the downloading of new videos from a list of channels.
|
||||
|
||||
|
||||
## How it works currently(legacy)
|
||||
In the legacy folder you will find files that are currently in my archive project. How it works is
|
||||
|
@ -49,7 +54,10 @@ This is mainly why we need a new system using a database.
|
|||
```
|
||||
./videos/{channel_id}/{upload_date}/{video_id}/video_title.mp4
|
||||
```
|
||||
For the new system using a blob like storage will be key. I had the following in mind.
|
||||
For the new system using a blob like storage will be key. I had the following in mind. It will be an independant
|
||||
random key and not the YouTube video ID because I have notices that multiple real videos exist under the same key by
|
||||
uploaders who replace old videos.
|
||||
|
||||
```
|
||||
-| data
|
||||
| - videos
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
#Import os Library
|
||||
import os
|
||||
import datetime
|
||||
import json
|
||||
|
||||
def print_current_time(give=False):
|
||||
time = datetime.datetime.now().replace(microsecond=0)
|
||||
print(f'--- It is {time} ---')
|
||||
return time
|
||||
|
||||
with open('lockfile', 'w') as file:
|
||||
data = {'time': print_current_time(), 'PID': os.getpid()}
|
||||
file.write(json.dumps(data, default=str))
|
|
@ -0,0 +1,35 @@
|
|||
import os
|
||||
from flask import Flask
|
||||
from flask_caching import Cache
|
||||
from .filters import pretty_time
|
||||
|
||||
|
||||
|
||||
def create_app(test_config=None):
|
||||
config = {'MONGO_CONNECTION': os.environ.get('AYTA_MONGOCONNECTION', 'mongodb://root:example@192.168.66.140:27017'),
|
||||
'S3_CONNECTION': os.environ.get('AYTA_S3CONNECTION', '192.168.66.111:9001'),
|
||||
'S3_ACCESSKEY': os.environ.get('AYTA_S3ACCESSKEY', 'lnUiGClFVXVuZbsr'),
|
||||
'S3_SECRETKEY': os.environ.get('AYTA_S3SECRETKEY', 'Qz9NG7rpcOWdK2WL'),
|
||||
'CACHE_TYPE': os.environ.get('AYTA_CACHETYPE', 'SimpleCache'),
|
||||
'CACHE_DEFAULT_TIMEOUT': os.environ.get('AYTA_CACHETIMEOUT', 300)
|
||||
}
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_mapping(config)
|
||||
|
||||
app.jinja_env.filters['pretty_time'] = pretty_time
|
||||
|
||||
from .blueprints import watch
|
||||
from .blueprints import index
|
||||
from .blueprints import admin
|
||||
from .blueprints import search
|
||||
from .blueprints import channel
|
||||
|
||||
app.register_blueprint(watch.bp)
|
||||
app.register_blueprint(index.bp)
|
||||
app.register_blueprint(admin.bp)
|
||||
app.register_blueprint(search.bp)
|
||||
app.register_blueprint(channel.bp)
|
||||
app.add_url_rule("/", endpoint="base")
|
||||
|
||||
return app
|
|
@ -0,0 +1,100 @@
|
|||
import functools
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint
|
||||
from flask import g
|
||||
from flask import redirect
|
||||
from flask import render_template
|
||||
from flask import request
|
||||
from flask import url_for
|
||||
from ..nosql import get_nosql
|
||||
from ..s3 import get_s3
|
||||
from ..dlp import checkChannelId, getChannelInfo
|
||||
|
||||
bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@bp.route('')
|
||||
def base():
|
||||
return render_template('admin/index.html')
|
||||
|
||||
@bp.route('/channels', methods=['GET', 'POST'])
|
||||
def channels():
|
||||
channels = {}
|
||||
generic = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
channelId = request.form.get('channel_id', None)
|
||||
originalName = request.form.get('original_name', None)
|
||||
addedDate = request.form.get('added_date', None)
|
||||
|
||||
### add some validation
|
||||
|
||||
addedDate = datetime.strptime(addedDate, '%Y-%m-%d')
|
||||
|
||||
if checkChannelId(channelId) is False:
|
||||
channelId, originalName = getChannelInfo(channelId, ('channel_id', 'uploader'))
|
||||
|
||||
if not get_nosql().insert_new_channel(channelId, originalName, addedDate):
|
||||
return 'Error inserting new channel, you probably made a mistake somewhere'
|
||||
|
||||
return redirect(url_for('admin.channel', channelId=channelId))
|
||||
|
||||
generic['currentDate'] = datetime.utcnow()
|
||||
channelIds = get_nosql().list_all_channels()
|
||||
|
||||
for channelId in channelIds:
|
||||
channels[channelId] = get_nosql().get_channel_info(channelId)
|
||||
channels[channelId]['video_count'] = get_nosql().get_channel_videos_count(channelId)
|
||||
|
||||
return render_template('admin/channels.html', channels=channels, generic=generic)
|
||||
|
||||
@bp.route('/channel/<channelId>', methods=['GET', 'POST'])
|
||||
def channel(channelId):
|
||||
if request.method == 'POST':
|
||||
task = request.form.get('task', None)
|
||||
key = request.form.get('key', None)
|
||||
value = request.form.get('value', None)
|
||||
|
||||
if task == 'update-value':
|
||||
if key == 'active' and value is None: # html checkbox is not present if not checked
|
||||
value = False
|
||||
elif key == 'active' and value is not None: # html checkbox is False if checked
|
||||
value = True
|
||||
|
||||
if key == 'added_date':
|
||||
value = datetime.strptime(value, '%Y-%m-%d')
|
||||
|
||||
get_nosql().update_channel_key(channelId, key, value)
|
||||
|
||||
channelInfo = get_nosql().get_channel_info(channelId)
|
||||
|
||||
if not channelInfo:
|
||||
return 'That channel ID does not exist in the system'
|
||||
|
||||
#if channelInfo.get('added_date'):
|
||||
# channelInfo['added_date'] = channelInfo['added_date'].strftime("%Y-%m-%d")
|
||||
|
||||
return render_template('admin/channel.html', channelInfo=channelInfo)
|
||||
|
||||
@bp.route('/runs', methods=['GET', 'POST'])
|
||||
def runs():
|
||||
if request.method == 'POST':
|
||||
task = request.form.get('task', None)
|
||||
if task == 'clean_runs':
|
||||
get_nosql().clean_runs()
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
runs = reversed(list(get_nosql().get_runs()))
|
||||
return render_template('admin/runs.html', runs=runs)
|
||||
|
||||
@bp.route('/run/<runId>', methods=['GET', 'POST'])
|
||||
def run(runId):
|
||||
run = get_nosql().get_run(runId)
|
||||
return render_template('admin/run.html', run=run)
|
||||
|
||||
@bp.route('/files', methods=['GET', 'POST'])
|
||||
def files():
|
||||
run = get_s3().list_objects()
|
||||
return str(run)
|
|
@ -0,0 +1,39 @@
|
|||
import functools
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint
|
||||
from flask import g
|
||||
from flask import redirect
|
||||
from flask import render_template
|
||||
from flask import request
|
||||
from flask import url_for
|
||||
from ..nosql import get_nosql
|
||||
from ..s3 import get_s3
|
||||
|
||||
bp = Blueprint('channel', __name__, url_prefix='/channel')
|
||||
|
||||
@bp.route('')
|
||||
def base():
|
||||
channels = {}
|
||||
channelIds = get_nosql().list_all_channels()
|
||||
|
||||
for channelId in channelIds:
|
||||
channels[channelId] = get_nosql().get_channel_info(channelId)
|
||||
channels[channelId]['video_count'] = get_nosql().get_channel_videos_count(channelId)
|
||||
|
||||
return render_template('channel/index.html', channels=channels)
|
||||
|
||||
@bp.route('/<channelId>')
|
||||
def channel(channelId):
|
||||
channelInfo = get_nosql().get_channel_info(channelId)
|
||||
|
||||
if not channelInfo:
|
||||
return 'That channel ID does not exist in the system'
|
||||
|
||||
videoIds = get_nosql().get_channel_videoIds(channelId)
|
||||
|
||||
videos = {}
|
||||
for videoId in videoIds:
|
||||
videos[videoId] = get_nosql().get_video_info(videoId, limited=True)
|
||||
|
||||
return render_template('channel/channel.html', channel=channelInfo, videos=videos)
|
|
@ -0,0 +1,12 @@
|
|||
from flask import Blueprint
|
||||
from flask import render_template
|
||||
from flask import request
|
||||
from flask import url_for
|
||||
from werkzeug.security import check_password_hash
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
bp = Blueprint('index', __name__, url_prefix='/')
|
||||
|
||||
@bp.route('/', methods=['GET'])
|
||||
def base():
|
||||
return render_template('index.html')
|
|
@ -0,0 +1,16 @@
|
|||
import functools
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint
|
||||
from flask import g
|
||||
from flask import redirect
|
||||
from flask import render_template
|
||||
from flask import request
|
||||
from flask import url_for
|
||||
from ..nosql import get_nosql
|
||||
|
||||
bp = Blueprint('search', __name__, url_prefix='/search')
|
||||
|
||||
@bp.route('/')
|
||||
def base():
|
||||
return render_template('search/index.html', stats=get_nosql().gen_stats())
|
|
@ -0,0 +1,27 @@
|
|||
import functools
|
||||
|
||||
from flask import Blueprint
|
||||
from flask import g
|
||||
from flask import redirect
|
||||
from flask import render_template
|
||||
from flask import request
|
||||
from flask import url_for
|
||||
from werkzeug.security import check_password_hash
|
||||
from werkzeug.security import generate_password_hash
|
||||
from ..nosql import get_nosql
|
||||
|
||||
bp = Blueprint('watch', __name__, url_prefix='/watch')
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
def base():
|
||||
render = {}
|
||||
|
||||
vGet = request.args.get('v')
|
||||
|
||||
if not get_nosql().check_exists(vGet):
|
||||
return render_template('watch/404.html')
|
||||
|
||||
|
||||
render['info'] = get_nosql().get_video_info(vGet)
|
||||
render['params'] = request.args.get('v')
|
||||
return render_template('watch/index.html', render=render)
|
|
@ -0,0 +1,36 @@
|
|||
import yt_dlp
|
||||
|
||||
|
||||
def checkChannelId(channelId):
|
||||
if len(channelId) < 24: # channelId lengths are 24 characters
|
||||
return False
|
||||
|
||||
if len(channelId) > 25: # But some are 25, idk why
|
||||
return False
|
||||
|
||||
if channelId[0:2] not in ['UC', 'UU']:
|
||||
return False
|
||||
|
||||
if channelId[0:2] == 'UU':
|
||||
return f'UC{channelId[2:]}'
|
||||
|
||||
if channelId[0:2] == 'UC':
|
||||
return channelId
|
||||
|
||||
def getChannelInfo(url, keys=['channel_id']):
|
||||
ydl_opts = {'skip_download': 1,
|
||||
'extract_flat': True,
|
||||
'playlist_items': ':1',
|
||||
'lazy_playlist': 1,
|
||||
'quiet': 1}
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
|
||||
|
||||
|
||||
channelInfo = ydl.sanitize_info(info)
|
||||
|
||||
if channelInfo.get('channel_id') is None:
|
||||
return 'UCxxxxxxxxxxxxxxxxxxxxxx'
|
||||
|
||||
return tuple([channelInfo.get(x) for x in keys])
|
|
@ -0,0 +1,3 @@
|
|||
def pretty_time(seconds):
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
return '{:3}:{:02} minutes'.format(int(minutes), int(seconds))
|
|
@ -0,0 +1,186 @@
|
|||
import json
|
||||
import pymongo
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
from flask import current_app
|
||||
from flask import g
|
||||
|
||||
##########################################
|
||||
# SETUP FLASK #
|
||||
##########################################
|
||||
|
||||
def get_nosql():
|
||||
"""Connect to the application's configured database. The connection is unique for each request and will be reused if this is called again."""
|
||||
if "nosql" not in g:
|
||||
g.nosql = Mango(current_app.config["MONGO_CONNECTION"])
|
||||
|
||||
return g.nosql
|
||||
|
||||
|
||||
def close_nosql(e=None):
|
||||
"""If this request connected to the database, close the connection."""
|
||||
nosql = g.pop("nosql", None)
|
||||
|
||||
if nosql is not None:
|
||||
nosql.close()
|
||||
|
||||
def init_app(app):
|
||||
"""Register database functions with the Flask app. This is called by the application factory."""
|
||||
app.teardown_appcontext(close_nosql)
|
||||
#app.cli.add_command(init_db_command)
|
||||
|
||||
|
||||
##########################################
|
||||
# ORM #
|
||||
##########################################
|
||||
class Mango:
|
||||
def __init__(self, connect):
|
||||
try:
|
||||
self.client = pymongo.MongoClient(connect)
|
||||
self.channels = self.client['ayta']['channels']
|
||||
self.info_json = self.client['ayta']['info_json']
|
||||
self.download_queue = self.client['ayta']['download_queue']
|
||||
self.run_log = self.client['ayta']['run_log']
|
||||
self.channel_log = self.client['ayta']['channel_log']
|
||||
|
||||
#self.channels.create_index([('id', pymongo.ASCENDING)], unique=True)
|
||||
except ConnectionError:
|
||||
print('MongoDB connection error')
|
||||
|
||||
def check_exists(self, vId):
|
||||
""" Returns BOOL; Given positional argument which is a valid or invalid YouTube video ID STR"""
|
||||
if self.info_json.count_documents({'id': vId}) >= 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
def gen_stats(self):
|
||||
""" Returns DICT; Channel statistics given the dict key """
|
||||
stats = {}
|
||||
|
||||
stats['videos'] = self.info_json.count_documents({})
|
||||
stats['channels'] = self.channels.count_documents({})
|
||||
stats['queue'] = self.download_queue.count_documents({})
|
||||
|
||||
return stats
|
||||
|
||||
def list_all_channels(self, active=False):
|
||||
""" Returns a SET of YouTube channel ID's; Depending on given positional BOOL only active channels or everything"""
|
||||
search_terms = {}
|
||||
|
||||
if active:
|
||||
search_terms['active'] = True
|
||||
|
||||
channels = []
|
||||
for channel in self.channels.find(search_terms, {'id': 1}):
|
||||
channels.append(channel['id'])
|
||||
return tuple(channels)
|
||||
|
||||
|
||||
def get_last_etag(self, channelId):
|
||||
""" string of channel etag if exists or None """
|
||||
data = self.channels.find_one({'id': channelId}, {'fast.etag': 1})
|
||||
try:
|
||||
return data['fast']['etag']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def get_channel_videos_count(self, channelId):
|
||||
"""
|
||||
Returns int of total video count on specific channel
|
||||
"""
|
||||
return self.info_json.count_documents({'channel_id': channelId})
|
||||
|
||||
def get_video_info(self, videoId, limited=False):
|
||||
"""
|
||||
"""
|
||||
|
||||
if limited:
|
||||
projection = {'_id': 1, 'id': 1, 'title': 1, 'upload_date': 1, 'description': 1}
|
||||
else:
|
||||
projection = {}
|
||||
|
||||
return self.info_json.find_one({'id': videoId}, projection)
|
||||
|
||||
def get_channel_videoIds(self, channelId):
|
||||
ids = []
|
||||
for video in self.info_json.find({'channel_id': channelId}, {'id': 1}):
|
||||
ids.append(video['id'])
|
||||
return tuple(ids)
|
||||
|
||||
def get_channel_info(self, channelId):
|
||||
return self.channels.find_one({'id': channelId})
|
||||
|
||||
def get_runs(self):
|
||||
return self.run_log.find({})
|
||||
|
||||
def get_run(self, runId):
|
||||
run = self.run_log.find_one({'_id': ObjectId(runId)})
|
||||
run['channel_runs'] = list(self.channel_log.find({'run_id': ObjectId(runId)}))
|
||||
|
||||
return run
|
||||
|
||||
def update_fast_etag(self, channel, etag):
|
||||
self.channels.update_one({'id': channel}, {"$set": {"fast.etag": etag, "fast.lastrun": get_utc()}})
|
||||
return etag
|
||||
|
||||
def update_channel_state(self, channelId, state):
|
||||
self.channels.update_one({'id': channelId}, {"$set": {"active": bool(state)}})
|
||||
return True
|
||||
|
||||
def update_channel_key(self, channelId, key, value):
|
||||
self.channels.update_one({'id': channelId}, {"$set": {key: value}})
|
||||
return True
|
||||
|
||||
def insert_download_queue(self, video):
|
||||
if not self.download_queue.count_documents({'id': video}) >= 1:
|
||||
return self.download_queue.insert_one({'id': video}).inserted_id
|
||||
|
||||
def insert_new_channel(self, channelId, originalName, addedDate):
|
||||
if self.channels.count_documents({'id': channelId}) >= 1:
|
||||
return False
|
||||
|
||||
return self.channels.insert_one({'id': channelId, 'original_name': originalName, 'added_date': addedDate, 'active': False}).inserted_id
|
||||
|
||||
def clean_runs(self, keep=3):
|
||||
runs = list(self.run_log.find({}, {'_id': 1}).sort('time', pymongo.ASCENDING))
|
||||
|
||||
for x in range(keep):
|
||||
if len(runs) == 0:
|
||||
return True
|
||||
runs.pop(-1)
|
||||
|
||||
# runs is now a list of objectid's to remove
|
||||
|
||||
for run in runs:
|
||||
self.channel_log.delete_many({'run_id': run['_id']})
|
||||
self.run_log.delete_one({'_id': run['_id']})
|
||||
|
||||
return True
|
||||
|
||||
##########################################
|
||||
# HELPER FUNCTIONS #
|
||||
##########################################
|
||||
|
||||
def clean_info_json(originalInfo, format='dict'):
|
||||
|
||||
unneeded_keys = ['automatic_captions', 'formats', 'thumbnails', 'thumbnail', 'subtitles', 'http_headers', 'url', '_filename', 'thumbnail']
|
||||
|
||||
if type(originalInfo) != dict:
|
||||
try:
|
||||
originalInfo = json.loads(originalInfo)
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
print('The given variable was not a valid dictionary. We tried to parse it anyway as JSON but that failed also!')
|
||||
|
||||
for key in unneeded_keys:
|
||||
data.pop(key, None)
|
||||
|
||||
if format == 'dict':
|
||||
return originalInfo
|
||||
elif format == 'str':
|
||||
return json.dumps(originalInfo)
|
||||
else:
|
||||
print('The requested output format is not supported!')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
mango = Mango('mongodb://root:example@192.168.66.140:27017')
|
|
@ -0,0 +1,50 @@
|
|||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
from flask import current_app
|
||||
from flask import g
|
||||
|
||||
##########################################
|
||||
# SETUP FLASK #
|
||||
##########################################
|
||||
|
||||
def get_s3():
|
||||
"""Connect to the application's configured database. The connection is unique for each request and will be reused if this is called again."""
|
||||
if "s3" not in g:
|
||||
g.s3 = Mineral(current_app.config["S3_CONNECTION"], current_app.config["S3_ACCESSKEY"], current_app.config["S3_SECRETKEY"])
|
||||
|
||||
return g.s3
|
||||
|
||||
|
||||
def close_s3(e=None):
|
||||
"""If this request connected to the database, close the connection."""
|
||||
s3 = g.pop("s3", None)
|
||||
|
||||
if s3 is not None:
|
||||
s3.close()
|
||||
|
||||
def init_app(app):
|
||||
"""Register database functions with the Flask app. This is called by the application factory."""
|
||||
app.teardown_appcontext(close_s3)
|
||||
#app.cli.add_command(init_db_command)
|
||||
|
||||
##########################################
|
||||
# ORM #
|
||||
##########################################
|
||||
|
||||
class Mineral:
|
||||
def __init__(self, location, access, secret):
|
||||
try:
|
||||
self.client = Minio(location, access_key=access, secret_key=secret, secure=False)
|
||||
except S3Error as exc:
|
||||
print('Minio connection error ', exc)
|
||||
|
||||
def list_objects(self, bucket='ytarchive'):
|
||||
ret = self.client.list_objects(bucket, '')
|
||||
rett = []
|
||||
|
||||
for r in ret:
|
||||
print(r.object_name, flush=True)
|
||||
rett.append(r)
|
||||
|
||||
return rett
|
|
@ -0,0 +1,9 @@
|
|||
body {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1 0 auto;
|
||||
}
|
File diff suppressed because one or more lines are too long
Binary file not shown.
After Width: | Height: | Size: 318 B |
Binary file not shown.
Binary file not shown.
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
After Width: | Height: | Size: 442 KiB |
|
@ -0,0 +1,8 @@
|
|||
$(document).ready(function(){
|
||||
$("#filter_query").on("keyup", function() {
|
||||
var value = $(this).val().toLowerCase();
|
||||
$(".filterable").filter(function() {
|
||||
$(this).toggle($(this).text().toLowerCase().indexOf(value) > -1)
|
||||
});
|
||||
});
|
||||
});
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,44 @@
|
|||
function channelSort() {
|
||||
const sortOption = document.querySelector(".sort").value;
|
||||
const [sortBy, direction] = sortOption.split("-");
|
||||
const isInt = sortBy !== "search";
|
||||
const container = document.querySelector(".channels.flex-grid");
|
||||
[...container.children]
|
||||
.sort((a,b)=>{
|
||||
const dir = direction ? 1 : -1;
|
||||
let valA = a.dataset[sortBy];
|
||||
let valB = b.dataset[sortBy];
|
||||
if (isInt) {
|
||||
valA = parseInt(valA);
|
||||
valB = parseInt(valB);
|
||||
}
|
||||
return (valA>valB?1:-1)*dir;
|
||||
})
|
||||
.forEach(node=>container.appendChild(node));
|
||||
}
|
||||
|
||||
function channelSearch() {
|
||||
let searchTerm = document.querySelector(".search").value.toLowerCase();
|
||||
const allowedClasses = [];
|
||||
const filteredClasses = [];
|
||||
|
||||
document.querySelectorAll('.searchable').forEach((e) => {
|
||||
let filtered = false;
|
||||
for (const c of allowedClasses) {
|
||||
if (!e.querySelector(`.${c}`)) filtered = true;
|
||||
}
|
||||
for (const c of filteredClasses) {
|
||||
if (e.querySelector(`.${c}`)) filtered = true;
|
||||
}
|
||||
|
||||
if (!filtered && (searchTerm === "" || e.dataset.search.toLowerCase().includes(searchTerm))) {
|
||||
e.classList.remove("hide");
|
||||
} else {
|
||||
e.classList.add("hide");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
channelSearch();
|
||||
});
|
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
|
@ -0,0 +1,406 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap');
|
||||
|
||||
/* Base */
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.4285em;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #4183c4;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
/* Maybe we can fix this later */
|
||||
outline: -webkit-focus-ring-color auto 1px;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: auto;
|
||||
max-width: 1127px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.flex-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.card {
|
||||
width: 33.33333333%;
|
||||
}
|
||||
}
|
||||
|
||||
.card .content * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card > .inner {
|
||||
display: block;
|
||||
border: solid #bdbdbd 1px;
|
||||
border-radius: 4px;
|
||||
margin: 14px;
|
||||
overflow-wrap: break-word;
|
||||
transition: border 0.1s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.card > .inner:hover {
|
||||
border: solid #888 1px;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card > .inner:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.card > .inner > .content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.card > .inner > .content .title {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
line-height: 1.3;
|
||||
margin-top: -2px;
|
||||
color: rgba(0,0,0,.85);
|
||||
}
|
||||
|
||||
.card > .inner > .content .meta {
|
||||
color: rgba(0,0,0,.4);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.card > .inner > .content .description {
|
||||
color: rgba(0,0,0,.68);
|
||||
}
|
||||
|
||||
.card > .inner > .image {
|
||||
width: 100%;
|
||||
border-radius: 3.1px 3.1px 0 0;
|
||||
}
|
||||
|
||||
.card > .inner > .image > img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.channels .head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 20px 14px 0 14px;
|
||||
}
|
||||
|
||||
.channels .head .title {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.channels .head .title h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.channels .subtitle {
|
||||
margin: 2%;
|
||||
}
|
||||
|
||||
/* Header bar */
|
||||
.header {
|
||||
background: #1b1c1d;
|
||||
color: white;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header .item, .header a.item {
|
||||
color: rgba(255,255,255,.9);
|
||||
padding: 13px 16px;
|
||||
height: 14px;
|
||||
display: inline-block;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
transition: background .1s ease;
|
||||
}
|
||||
|
||||
.header .item.primary {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header .item:hover {
|
||||
background-color: rgba(255,255,255,.08);
|
||||
}
|
||||
|
||||
.header .item:before {
|
||||
background: rgba(255,255,255,.08);
|
||||
position: absolute;
|
||||
content: '';
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
/* Home page */
|
||||
.home {
|
||||
padding-top: 105px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home .title {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.home a.button {
|
||||
color: #fff;
|
||||
background-color: #2185d0;
|
||||
font-size: 1.5rem;
|
||||
padding: 12px 36px;
|
||||
font-weight: 700;
|
||||
border-radius: 4px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.home a.button:hover {
|
||||
background-color: #1678c2;
|
||||
animation: hue 1.5s infinite linear;
|
||||
}
|
||||
|
||||
.home a.button:active {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
@-webkit-keyframes hue {
|
||||
from {
|
||||
filter: hue-rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
filter: hue-rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Video */
|
||||
.video {
|
||||
width: 100%;
|
||||
max-height: 75vh;
|
||||
}
|
||||
|
||||
.info {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.info .main {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.info .main .comments {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.info .main .subtitle {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.info .side {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.info .side .views {
|
||||
font-size: 18px;
|
||||
margin-bottom: 0.35em;
|
||||
}
|
||||
|
||||
.info .side .date {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info .side .uploader {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Icons */
|
||||
@font-face {
|
||||
font-family: 'icons';
|
||||
src: url('icons.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-family: 'icons' !important;
|
||||
speak: never;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.button .icon {
|
||||
margin: 0 6px 0 -3px;
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.icon.download:before {
|
||||
content: "\e900";
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.button {
|
||||
transition: background .1s ease;
|
||||
}
|
||||
|
||||
.button.download {
|
||||
display: inline-block;
|
||||
border-radius: 4px;
|
||||
color: rgba(0,0,0,.6);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
padding: 8px 21px;
|
||||
margin-bottom: 0.3em;
|
||||
background: #e0e1e2;
|
||||
}
|
||||
|
||||
.button.download:hover {
|
||||
background: #cacbcd;
|
||||
color: rgba(0,0,0,.8);
|
||||
}
|
||||
|
||||
.button.download:active {
|
||||
background: #babbbc;
|
||||
color: rgba(0,0,0,.9);
|
||||
}
|
||||
|
||||
/* Note */
|
||||
.note {
|
||||
margin: 14px;
|
||||
padding: 14px 14px 0 14px;
|
||||
color: rgba(0,0,0,.87);
|
||||
border: solid rgba(0,0,0,.87) 1px;
|
||||
background-color: #f8f8f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.note.yellow {
|
||||
color: #b58105;
|
||||
border: solid #b58105 1px;
|
||||
background-color: #fff8db;
|
||||
}
|
||||
|
||||
.note.red {
|
||||
color: #db2828;
|
||||
border: solid #db2828 1px;
|
||||
background-color: #ffe8e6;
|
||||
}
|
||||
|
||||
.note.green {
|
||||
color: #1ebc30;
|
||||
border: solid #1ebc30 1px;
|
||||
background-color: #e5f9e7;
|
||||
}
|
||||
|
||||
.note.blue {
|
||||
color: #276f86;
|
||||
border: solid #276f86 1px;
|
||||
background-color: #f8ffff;
|
||||
}
|
||||
|
||||
.note .title {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Other */
|
||||
.sort, .search {
|
||||
border: 1px solid rgba(34,36,38,.15);
|
||||
border-radius: 4px;
|
||||
outline: 0;
|
||||
color: rgba(0,0,0,.87);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.sort:focus, .search:focus {
|
||||
border-color: #85b7d9;
|
||||
color: rgba(0,0,0,.8);
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 7px 14px;
|
||||
}
|
||||
|
||||
.ytlink {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 14px;
|
||||
color: rgba(0,0,0,.6);
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none!important;
|
||||
}
|
||||
|
||||
.removed {
|
||||
background-color: #ffb4df;
|
||||
}
|
||||
|
||||
.unlisted {
|
||||
background-color: #fff1b4;
|
||||
}
|
||||
|
||||
.removed.unlisted {
|
||||
background-color: #ffa1a1;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
min-height: min(12.6vw, 200px);
|
||||
}
|
||||
|
||||
.comment p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.comment .reply {
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.comment, .reply {
|
||||
margin-bottom: 10px;
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
{% extends 'material_base.html' %}
|
||||
{% block title %}Channel administration page | AYTA{% endblock %}
|
||||
{% block description %}Channel administration page of the AYTA system{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<h4>{{ channelInfo.original_name }} administration page</h4>
|
||||
<p>The update actions below directly apply to the database!</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col s12 l4">
|
||||
{% for item in channelInfo %}
|
||||
<form method="POST">
|
||||
<div class="input-field">
|
||||
<span class="supporting-text">{{ item }}</span>
|
||||
<input class="validate" type="text" value="{{ item }}" name="key" hidden>
|
||||
</div>
|
||||
|
||||
{% if channelInfo.get(item) is boolean %}
|
||||
<div class="input-field">
|
||||
<div class="switch">
|
||||
<label>Off<input type="checkbox" value="{{ channelInfo.get(item) }}" name="value" {% if channelInfo.get(item) == True %}checked{% endif %}><span class="lever"></span>On</label>
|
||||
</div>
|
||||
</div>
|
||||
{% elif channelInfo.get(item).__class__.__name__ == 'datetime' %}
|
||||
<div class="input-field">
|
||||
<input type="text" class="datepicker" name="value" value="{{ channelInfo.get(item).strftime('%Y-%m-%d') }}">
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var elems = document.querySelectorAll('.datepicker');
|
||||
var instances = M.Datepicker.init(elems, {
|
||||
'autoClose': true,
|
||||
'format': 'yyyy-mm-dd'
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="input-field">
|
||||
<input type="text" value="{{ channelInfo.get(item) }}" name="value" {% if item == '_id' %}disabled{% endif %}>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button class="btn icon-right waves-effect waves-light" type="submit" name="task" value="update-value" {% if item == '_id' %}disabled{% endif %}>Update value</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
|
@ -0,0 +1,96 @@
|
|||
{% extends 'material_base.html' %}
|
||||
{% block title %}Channels administration page | AYTA{% endblock %}
|
||||
{% block description %}Channels administration page of the AYTA system{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<h4>Channels administration page</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<h5>Global channel options</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col s12 l4 m-4">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<span class="card-title">Add new channel</span>
|
||||
<form method="post">
|
||||
<div class="input-field">
|
||||
<input required placeholder="Channel ID or YouTube video tab URL" name="channel_id" type="text" class="validate">
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<input placeholder="Original Name. Leave empty if above is URL" name="original_name" type="text" class="validate">
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<input type="text" class="datepicker" name="added_date" value="{{ generic.get('currentDate').strftime('%Y-%m-%d') }}">
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var elems = document.querySelectorAll('.datepicker');
|
||||
var instances = M.Datepicker.init(elems, {
|
||||
'autoClose': true,
|
||||
'format': 'yyyy-mm-dd'
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
<button class="btn mt-4" type="submit" name="action" value="add_channel">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col s12 l4 m-4">
|
||||
<div class="card green">
|
||||
<div class="card-content white-text">
|
||||
<span class="card-title">Placeholder</span>
|
||||
<p>I am a very simple card. I am good at containing small bits of information. I am convenient because I require little markup to use effectively.</p>
|
||||
</div>
|
||||
<div class="card-action">
|
||||
<a href="#">This is a link</a>
|
||||
<a href="#">This is a link</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col s12 l4 m-4">
|
||||
<div class="card green">
|
||||
<div class="card-content white-text">
|
||||
<span class="card-title">Placeholder</span>
|
||||
<p>I am a very simple card. I am good at containing small bits of information. I am convenient because I require little markup to use effectively.</p>
|
||||
</div>
|
||||
<div class="card-action">
|
||||
<a href="#">This is a link</a>
|
||||
<a href="#">This is a link</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="row">
|
||||
<div class="col s6 l9">
|
||||
<h5>Edit existing channels</h5>
|
||||
</div>
|
||||
<div class="col s6 l3 m-4 input-field">
|
||||
<input id="filter_query" type="text">
|
||||
<label for="filter_query">Filter results</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% for channel in channels %}
|
||||
<div class="col s6 l4 m-4 filterable">
|
||||
<a href="{{ url_for('admin.channel', channelId=channel) }}">
|
||||
<div class="card black-text">
|
||||
<div class="card-content">
|
||||
<span class="card-title">{{ channels[channel].get('original_name') }}</span>
|
||||
<p class="grey-text">{{ channels[channel].get('id') }}</p>
|
||||
<p>{{ channels[channel].get('added_date') }} | <b>Active:</b> {{ channels[channel].get('active') }} | <b>Videos:</b> {{ channels[channel].get('video_count') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,46 @@
|
|||
{% extends 'base.html' %}
|
||||
{% block title %}Admin page | AYTA{% endblock %}
|
||||
{% block description %}Administration page of the AYTA system{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container channels">
|
||||
<div class="head">
|
||||
<div class="title">
|
||||
<h1 style="display: inline-block;">Administration page</h1>
|
||||
</div>
|
||||
<div>
|
||||
<select name="sort" class="sort{sort}" oninput="channelSort()">
|
||||
<option value="name-a">Name (asc)</option>
|
||||
<option value="name-d">Name (desc)</option>
|
||||
</select>
|
||||
<input type="text" class="search" oninput="channelSearch()" placeholder="Search...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="channels flex-grid">
|
||||
<div class="card" data-search="system">
|
||||
<a href="{{ url_for('admin.channels') }}" class="inner">
|
||||
<div class="content">
|
||||
<div class="title">System</div>
|
||||
<div class="meta">Internal system settings</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card" data-search="channels">
|
||||
<a href="{{ url_for('admin.channels') }}" class="inner">
|
||||
<div class="content">
|
||||
<div class="title">Channels</div>
|
||||
<div class="meta">Manage channels in the system</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card" data-search="runs">
|
||||
<a href="{{ url_for('admin.runs') }}" class="inner">
|
||||
<div class="content">
|
||||
<div class="title">Archive runs</div>
|
||||
<div class="meta">Look at the cron run logs</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,24 @@
|
|||
{% extends 'base.html' %}
|
||||
{% block title %}Runs administration page | AYTA{% endblock %}
|
||||
{% block description %}Cron Runs administration page of the AYTA system{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container channels">
|
||||
<div class="head">
|
||||
<div class="title">
|
||||
<h1 style="display: inline-block;">Cron Run administration page</h1>
|
||||
<h2 class="subtitle">{{ run.get('_id') }}</h2>
|
||||
<p><b>Started at</b> {{ run.get('time') }}</p>
|
||||
<p><b>Finished at</b> {{ run.get('finish_time', 'Probably still running') }}</p>
|
||||
{% for channel_run in run.get('channel_runs') %}
|
||||
<hr>
|
||||
<p><b>Run ID</b> {{ channel_run.get('_id') }}</p>
|
||||
<p><b>Channel ID</b> {{ channel_run.get('id') }} | <b>Time</b> {{ channel_run.get('time') }} | <b>Exit code</b> {{ channel_run.get('exit_code') }}</p>
|
||||
<textarea class="info" id={{ channel_run.get('_id') }}>{{ channel_run.get('log') }}</textarea>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,31 @@
|
|||
{% extends 'base.html' %}
|
||||
{% block title %}Runs administration page | AYTA{% endblock %}
|
||||
{% block description %}Cron Runs administration page of the AYTA system{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container channels">
|
||||
<div class="head">
|
||||
<div class="title">
|
||||
<h1 style="display: inline-block;">Runs administration page</h1>
|
||||
</div>
|
||||
<form class="center" method="POST">
|
||||
<input type="submit" value="clean_runs" name="task">
|
||||
</form>
|
||||
</div>
|
||||
<h2 class="subtitle">Cron runs list</h2>
|
||||
<div class="channels flex-grid">
|
||||
{% for run in runs %}
|
||||
<div class="card">
|
||||
<a href="{{ url_for('admin.run', runId=run.get('_id')) }}" class="inner">
|
||||
<div class="content">
|
||||
<div class="title">{{ run.get('_id') }}</div>
|
||||
<div class="meta">Amount of channel logs {{ run.get('channel_runs')|length }}</div>
|
||||
<div class="description"><b>Started</b> {{ run.get('time') }}</div>
|
||||
<div class="description"><b>Finished</b> {{ run.get('finish_time', 'Probably still running') }}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='style.css') }}">
|
||||
<script src="{{ url_for('static', filename='master.js') }}"></script>
|
||||
<title>{% block title %}{% endblock %} - AYTA</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="description" content="{% block description %}{% endblock %}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<a href="a" class="item primary">AYTA</a>
|
||||
<a href="{{ url_for('channel.base') }}" class="item">Channels</a>
|
||||
<a href="{{ url_for('admin.base') }}" class="item">Admin</a>
|
||||
<span class="item grow"></span>
|
||||
<a href="{{ url_for('search.base') }}" class="item">Stats</a>
|
||||
<a href="c" class="item">Help</a>
|
||||
</div>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,42 @@
|
|||
{% extends 'material_base.html' %}
|
||||
{% block title %}Videos of {{ channel.get('original_name') }} | AYTA{% endblock %}
|
||||
{% block description %}Video listings of {{ channel.get('original_name') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<h4>Channels lising page</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="row">
|
||||
<div class="col s6 l9">
|
||||
<h5>All videos archived of {{ channel.get('original_name') }}</h5>
|
||||
</div>
|
||||
<div class="col s6 l3 input-field">
|
||||
<input id="filter_query" type="text">
|
||||
<label for="filter_query">Filter results</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% for video in videos %}
|
||||
<div class="col s6 l4 m-4 filterable">
|
||||
<div class="card medium black-text">
|
||||
<a href="{{ url_for('watch.base') }}?v={{ video }}">
|
||||
<div class="card-image">
|
||||
<img loading="lazy" src="{{ url_for('static', filename='img/logo_text.png') }}">
|
||||
</div>
|
||||
</a>
|
||||
<div class="card-content activator">
|
||||
<span class="card-title">{{ videos[video].get('title') }}</span>
|
||||
<p class="grey-text">{{ videos[video].get('id') }} | {{ videos[video].get('upload_date') }}</p>
|
||||
</div>
|
||||
<div class="card-reveal">
|
||||
<span class="card-title truncate"><i class="material-icons right">close</i>{{ videos[video].get('title') }}</span>
|
||||
<p style="white-space: pre-wrap;">{{ videos[video].get('description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,36 @@
|
|||
{% extends 'material_base.html' %}
|
||||
{% block title %}Channels listing page | AYTA{% endblock %}
|
||||
{% block description %}Channels listing page of the AYTA system{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<h4>Channels lising page</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="row">
|
||||
<div class="col s6 l9">
|
||||
<h5>All channels</h5>
|
||||
</div>
|
||||
<div class="col s6 l3 m-4 input-field">
|
||||
<input id="filter_query" type="text">
|
||||
<label for="filter_query">Filter results</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% for channel in channels %}
|
||||
<div class="col s6 l4 m-4 filterable">
|
||||
<a href="{{ url_for('channel.channel', channelId=channel) }}">
|
||||
<div class="card black-text">
|
||||
<div class="card-content">
|
||||
<span class="card-title">{{ channels[channel].get('original_name') }}</span>
|
||||
<p class="grey-text">{{ channels[channel].get('id') }}</p>
|
||||
<p>{{ channels[channel].get('added_date') }} | <b>Active:</b> {{ channels[channel].get('active') }} | <b>Videos:</b> {{ channels[channel].get('video_count') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,18 @@
|
|||
{% extends 'material_base.html' %}
|
||||
{% block title %}Channels listing page | AYTA{% endblock %}
|
||||
{% block description %}Channels listing page of the AYTA system{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col s12 center-align">
|
||||
<h4>The Awesome YouTube Archive</h4>
|
||||
<p class="grey-text">Did you like a video? Youtube will delete it.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="row">
|
||||
<div class="col s12 center-align">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,53 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='css/materialize.min.css') }}">
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='css/custom.css') }}">
|
||||
<script src="{{ url_for('static', filename='js/jquery-3.7.1.slim.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/materialize.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/custom.js') }}"></script>
|
||||
<title>{% block title %}{% endblock %} - AYTA</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="description" content="{% block description %}{% endblock %}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body class="indigo lighten-5">
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-wrapper red accent-4">
|
||||
<ul id="nav-mobile" class="left">
|
||||
<li><a href="{{ url_for('channel.base') }}">Channels</a></li>
|
||||
<li><a href="{{ url_for('admin.base') }}">Admin</a></li>
|
||||
</ul>
|
||||
<a href="{{ url_for('index.base') }}" class="brand-logo center">AYTA</a>
|
||||
<ul id="nav-mobile" class="right">
|
||||
<li><a href="{{ url_for('search.base') }}">Search</a></li>
|
||||
<li><a href="{{ url_for('admin.base') }}">Help</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<div class="container">
|
||||
<noscript>Hey there, while I did build this in mind to minimize javascript usage, the experience would be much better if you would enable it!</noscript>
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
<footer class="page-footer red accent-4">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="s6">
|
||||
<h5>Awesome YouTube Archive</h5>
|
||||
<p>A custom content management system for archived YouTube videos!</p>
|
||||
</div>
|
||||
<div class="s6">
|
||||
<h4>Still in development, slowly...</h4>
|
||||
<h4>This is not a streaming website! Videos may buffer (a lot)!</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script>M.AutoInit();</script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,73 @@
|
|||
{% extends 'material_base.html' %}
|
||||
{% block title %}Admin page | AYTA{% endblock %}
|
||||
{% block description %}Statistics page of the AYTA system{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col s12 l3 m-4">
|
||||
<h4>Search the archive</h4>
|
||||
<p>Searching is currently not working and will probably not work for a long time until the database and backend is fully reworked.</p>
|
||||
<img class="responsive-img" src="{{ url_for('static', filename='img/mongo_meme.png') }}">
|
||||
<div class="divider"></div>
|
||||
<h5>Stats of the archive</h5>
|
||||
<ul class="collection">
|
||||
{% for stat in stats %}
|
||||
<li class="collection-item">
|
||||
<span class="title">{{ stat }}</span>
|
||||
<p>{{ stats[stat] }}</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col s12 l9 m-4">
|
||||
<div class="row">
|
||||
<div class="col s6 offset-s3">
|
||||
<img class="responsive-img" src="{{ url_for('static', filename='img/bing_chilling.png') }}">
|
||||
</div>
|
||||
<div class="col s12 center-align">
|
||||
<h5>"A big archive needs a search function." -Sun Tzu</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<form method="post" class="">
|
||||
<div class="row">
|
||||
<div class="col s12 m-4 input-field">
|
||||
<input id="first_name" type="text" placeholder=" " maxlength="20">
|
||||
<label for="first_name">First Name</label>
|
||||
<span class="supporting-text">Supporting Text</span>
|
||||
</div>
|
||||
<div class="col s12 m-4">
|
||||
<button class="btn icon-right waves-effect waves-light" type="submit" name="action" value="search">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="divider"></div>
|
||||
<table class="striped highlight responsive-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Item Name</th>
|
||||
<th>Item Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Alvin</td>
|
||||
<td>Eclair</td>
|
||||
<td>$0.87</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Alan</td>
|
||||
<td>Jellybean</td>
|
||||
<td>$3.76</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Jonathan</td>
|
||||
<td>Lollipop</td>
|
||||
<td>$7.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,9 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block header %}
|
||||
<h1>Watch</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>Sup nigga, the requested video is not available in this archive.</p>
|
||||
{% endblock %}
|
|
@ -0,0 +1,82 @@
|
|||
{% extends 'material_base.html' %}
|
||||
{% block title %}{{ render.get('info').get('title') }} | AYTA{% endblock %}
|
||||
{% block description %}Archived version of: render.get('info').get('title') by {{ render.get('info').get('uploader') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<h4>{{ render.get('info').get('title') }}</h4>
|
||||
</div>
|
||||
<div class="col s3">
|
||||
<p><b>Video by:</b> <a href="{{ url_for('channel.channel', channelId=render.get('info').get('channel_id')) }}">{{ render.get('info').get('uploader') }}</a></p>
|
||||
</div>
|
||||
<div class="col s3">
|
||||
<p><b>Upload date:</b> {{ render.get('info').get('upload_date') }}</p>
|
||||
</div>
|
||||
<div class="col s3">
|
||||
<p><b>Archive date:</b> {{ render.get('info').get('archive_date') }}</p>
|
||||
</div>
|
||||
<div class="col s3">
|
||||
<p><b>Video length:</b> {{ render.get('info').get('duration')|pretty_time }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col s12 center-align">
|
||||
<video controls class="responsive-video">
|
||||
<source src="https://archive.ventilaar.net/videos/automatic/{{ render.get('info').get('channel_id') }}/{{ render.get('info').get('id') }}/{{ render.get('info').get('title') }}.mp4">
|
||||
<source src="https://archive.ventilaar.net/videos/automatic/{{ render.get('info').get('channel_id') }}/{{ render.get('info').get('id') }}/{{ render.get('info').get('title') }}.webm">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col s12 l9 center-align mr-4">
|
||||
<div class="section">
|
||||
<div class="row">
|
||||
<div class="col s12 m9">
|
||||
<p>text</p>
|
||||
</div>
|
||||
<div class="col s12 m3 input-field">
|
||||
<select id="report">
|
||||
<option value="" disabled selected></option>
|
||||
<option value="auto-video">Auto/Video Problems</option>
|
||||
<option value="metadata">Incorrect metadata</option>
|
||||
<option value="illegal">Illegal video</option>
|
||||
</select>
|
||||
<label for="report">Report a problem</label>
|
||||
<button for="report" class="btn" type="submit" name="action">Submit report</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="section">
|
||||
<h5>Description</h5>
|
||||
<p style="white-space: pre-wrap;" class="left-align">{{ render.get('info').get('description') }}</p>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="section input-field">
|
||||
<h5>Full info JSON dump</h5>
|
||||
<textarea readonly class="materialize-textarea grey lighten">{{ render.get('info') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col s12 l3 ml-4">
|
||||
<div class="section">
|
||||
<h5>Categories</h5>
|
||||
<ul class="collection">
|
||||
{% for category in render.get('info').get('categories') %}
|
||||
<li class="collection-item">{{ category }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="section">
|
||||
<h5>Tags</h5>
|
||||
<ul class="collection">
|
||||
{% for tag in render.get('info').get('tags') %}
|
||||
<li class="collection-item">{{ tag }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,2 +0,0 @@
|
|||
This folder contains scripts and files for me to learn.
|
||||
One folder for scripts to get familiar with the youtube api and one to learn sqlalchemy
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#channelListResponse",
|
||||
"etag": "XGE15m_XBZE6EeJiDm0XQxNstoo",
|
||||
"pageInfo": {
|
||||
"totalResults": 1,
|
||||
"resultsPerPage": 5
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#channel",
|
||||
"etag": "egOkGWhVeOpO9_rlQlsA6M9ANoM",
|
||||
"id": "UCIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"brandingSettings": {
|
||||
"channel": {
|
||||
"title": "Ventilaar",
|
||||
"unsubscribedTrailer": "bAE8vsF7NOk"
|
||||
},
|
||||
"image": {
|
||||
"bannerExternalUrl": "https://lh3.googleusercontent.com/HFzslNqMSkYr05MJbLborbPkGdlXj9VbnhN_ddMNEIcGgq0Mn7HH1ez7AdDn4ME1CYJnUhV60A"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#channelListResponse",
|
||||
"etag": "y1h6rEr0J5ouOWpOgGoOoS0AEMo",
|
||||
"pageInfo": {
|
||||
"totalResults": 1,
|
||||
"resultsPerPage": 5
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#channel",
|
||||
"etag": "kPKzVTE16iBomHN8dehurcfIJJc",
|
||||
"id": "UCIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"contentDetails": {
|
||||
"relatedPlaylists": {
|
||||
"likes": "",
|
||||
"uploads": "UUIhnZFYX0BKWLrQ8ZAeop3Q"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#channelListResponse",
|
||||
"etag": "708n6f-7lWLQ3_pOjQI6oX7cYdc",
|
||||
"pageInfo": {
|
||||
"totalResults": 1,
|
||||
"resultsPerPage": 5
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#channel",
|
||||
"etag": "8PCnp1vs34M-37GWilYWzv5zkFc",
|
||||
"id": "UCIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"contentOwnerDetails": {}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#channelListResponse",
|
||||
"etag": "6zZ7b-Sjvi39ufu1M_5DmnCrXvQ",
|
||||
"pageInfo": {
|
||||
"totalResults": 1,
|
||||
"resultsPerPage": 5
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#channel",
|
||||
"etag": "srwaOjEQ-eWbrF4Jwm-pHEjM_C0",
|
||||
"id": "UCIhnZFYX0BKWLrQ8ZAeop3Q"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#channelListResponse",
|
||||
"etag": "6zZ7b-Sjvi39ufu1M_5DmnCrXvQ",
|
||||
"pageInfo": {
|
||||
"totalResults": 1,
|
||||
"resultsPerPage": 5
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#channel",
|
||||
"etag": "srwaOjEQ-eWbrF4Jwm-pHEjM_C0",
|
||||
"id": "UCIhnZFYX0BKWLrQ8ZAeop3Q"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#channelListResponse",
|
||||
"etag": "PkKBEUtjwuYKZQMu3Sa-G_IBGik",
|
||||
"pageInfo": {
|
||||
"totalResults": 1,
|
||||
"resultsPerPage": 5
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#channel",
|
||||
"etag": "DzT7IvhQP2Ak7ZNrnFUb_pVL-8s",
|
||||
"id": "UCIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"snippet": {
|
||||
"title": "Ventilaar",
|
||||
"description": "",
|
||||
"publishedAt": "2013-02-01T17:33:57Z",
|
||||
"thumbnails": {
|
||||
"default": {
|
||||
"url": "https://yt3.ggpht.com/ytc/AKedOLQaPCaGtBdSIdJyWKqfInM9690xF5lRiZFsH1Ew=s88-c-k-c0x00ffffff-no-rj",
|
||||
"width": 88,
|
||||
"height": 88
|
||||
},
|
||||
"medium": {
|
||||
"url": "https://yt3.ggpht.com/ytc/AKedOLQaPCaGtBdSIdJyWKqfInM9690xF5lRiZFsH1Ew=s240-c-k-c0x00ffffff-no-rj",
|
||||
"width": 240,
|
||||
"height": 240
|
||||
},
|
||||
"high": {
|
||||
"url": "https://yt3.ggpht.com/ytc/AKedOLQaPCaGtBdSIdJyWKqfInM9690xF5lRiZFsH1Ew=s800-c-k-c0x00ffffff-no-rj",
|
||||
"width": 800,
|
||||
"height": 800
|
||||
}
|
||||
},
|
||||
"localized": {
|
||||
"title": "Ventilaar",
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#channelListResponse",
|
||||
"etag": "TgWV2rh5Jdxh9-Kn80h90OX4zaE",
|
||||
"pageInfo": {
|
||||
"totalResults": 1,
|
||||
"resultsPerPage": 5
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#channel",
|
||||
"etag": "I5NhTv27RzQPm84SYWF0Kxj_fF8",
|
||||
"id": "UCIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"statistics": {
|
||||
"viewCount": "28198",
|
||||
"subscriberCount": "43",
|
||||
"hiddenSubscriberCount": false,
|
||||
"videoCount": "10"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#channelListResponse",
|
||||
"etag": "xMk5iBfKqV7JYx8UmVvj-f5B_ek",
|
||||
"pageInfo": {
|
||||
"totalResults": 1,
|
||||
"resultsPerPage": 5
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#channel",
|
||||
"etag": "tA-Nzl8Dcaeiz4d2QqIZSRi_Ic8",
|
||||
"id": "UCIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"status": {
|
||||
"privacyStatus": "public",
|
||||
"isLinked": true,
|
||||
"longUploadsStatus": "longUploadsUnspecified",
|
||||
"madeForKids": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#channelListResponse",
|
||||
"etag": "TcLbu6KU1V7LX2VaHgzVMSVVepE",
|
||||
"pageInfo": {
|
||||
"totalResults": 1,
|
||||
"resultsPerPage": 5
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#channel",
|
||||
"etag": "13n1OhIBiOozih5cSVdw6FE7_DE",
|
||||
"id": "UCIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"topicDetails": {
|
||||
"topicIds": [
|
||||
"/m/025zzc",
|
||||
"/m/04rlf",
|
||||
"/m/0bzvm2"
|
||||
],
|
||||
"topicCategories": [
|
||||
"https://en.wikipedia.org/wiki/Action_game",
|
||||
"https://en.wikipedia.org/wiki/Music",
|
||||
"https://en.wikipedia.org/wiki/Video_game_culture"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#playlistItemListResponse",
|
||||
"etag": "pGXxpygXglKdyZa1rK8GZq0elbo",
|
||||
"nextPageToken": "EAAaBlBUOkNBVQ",
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "nfobA1IZTeiQyY1buaTquUh6Wg4",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLnFEb25fOGxFMkcw"
|
||||
},
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "51QN7YYr_0HPRsL0pmt52DNvmlU",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLjhjZkhaMXBRb0tr"
|
||||
},
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "_QL_OHW4Ihz7q-C_YkSXd9TTWPY",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLk5nc1NkR0NzajV3"
|
||||
},
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "WUULr9af8RFt3iPwQizqFyzR0WU",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLkFlbkxxNUFYRFlZ"
|
||||
},
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "yDYU3q_h9LtS6dXDQd7R_kt3cLU",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLmoyRHhlOUROYzZn"
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"totalResults": 10,
|
||||
"resultsPerPage": 5
|
||||
}
|
||||
}
|
|
@ -1,226 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#playlistItemListResponse",
|
||||
"etag": "ZXdr_b1NtYNE9BIdukkfqodEgtM",
|
||||
"nextPageToken": "EAAaBlBUOkNBVQ",
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "pITlhDi47PnOIL_XA5VT8ixJmlU",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLnFEb25fOGxFMkcw",
|
||||
"snippet": {
|
||||
"publishedAt": "2021-02-02T19:52:32Z",
|
||||
"channelId": "UCIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"title": "GreatScott only breathing",
|
||||
"description": "",
|
||||
"thumbnails": {
|
||||
"default": {
|
||||
"url": "https://i.ytimg.com/vi/qDon_8lE2G0/default.jpg",
|
||||
"width": 120,
|
||||
"height": 90
|
||||
},
|
||||
"medium": {
|
||||
"url": "https://i.ytimg.com/vi/qDon_8lE2G0/mqdefault.jpg",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
"high": {
|
||||
"url": "https://i.ytimg.com/vi/qDon_8lE2G0/hqdefault.jpg",
|
||||
"width": 480,
|
||||
"height": 360
|
||||
},
|
||||
"standard": {
|
||||
"url": "https://i.ytimg.com/vi/qDon_8lE2G0/sddefault.jpg",
|
||||
"width": 640,
|
||||
"height": 480
|
||||
},
|
||||
"maxres": {
|
||||
"url": "https://i.ytimg.com/vi/qDon_8lE2G0/maxresdefault.jpg",
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
}
|
||||
},
|
||||
"channelTitle": "Ventilaar",
|
||||
"playlistId": "UUIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"position": 0,
|
||||
"resourceId": {
|
||||
"kind": "youtube#video",
|
||||
"videoId": "qDon_8lE2G0"
|
||||
},
|
||||
"videoOwnerChannelTitle": "Ventilaar",
|
||||
"videoOwnerChannelId": "UCIhnZFYX0BKWLrQ8ZAeop3Q"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "krUQWHLpeja5MIcZdz6tNJ-8iVE",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLjhjZkhaMXBRb0tr",
|
||||
"snippet": {
|
||||
"publishedAt": "2017-12-23T19:45:31Z",
|
||||
"channelId": "UCIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"title": "Friends in Low Places (Reupload)",
|
||||
"description": "Too bad that this beautiful masterpiece was removed.\nHere is the reupload",
|
||||
"thumbnails": {
|
||||
"default": {
|
||||
"url": "https://i.ytimg.com/vi/8cfHZ1pQoKk/default.jpg",
|
||||
"width": 120,
|
||||
"height": 90
|
||||
},
|
||||
"medium": {
|
||||
"url": "https://i.ytimg.com/vi/8cfHZ1pQoKk/mqdefault.jpg",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
"high": {
|
||||
"url": "https://i.ytimg.com/vi/8cfHZ1pQoKk/hqdefault.jpg",
|
||||
"width": 480,
|
||||
"height": 360
|
||||
},
|
||||
"standard": {
|
||||
"url": "https://i.ytimg.com/vi/8cfHZ1pQoKk/sddefault.jpg",
|
||||
"width": 640,
|
||||
"height": 480
|
||||
},
|
||||
"maxres": {
|
||||
"url": "https://i.ytimg.com/vi/8cfHZ1pQoKk/maxresdefault.jpg",
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
}
|
||||
},
|
||||
"channelTitle": "Ventilaar",
|
||||
"playlistId": "UUIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"position": 1,
|
||||
"resourceId": {
|
||||
"kind": "youtube#video",
|
||||
"videoId": "8cfHZ1pQoKk"
|
||||
},
|
||||
"videoOwnerChannelTitle": "Ventilaar",
|
||||
"videoOwnerChannelId": "UCIhnZFYX0BKWLrQ8ZAeop3Q"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "0rKTeYi5wlO5OXFKpjuVzfxanXo",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLk5nc1NkR0NzajV3",
|
||||
"snippet": {
|
||||
"publishedAt": "2017-11-29T17:09:45Z",
|
||||
"channelId": "UCIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"title": "KBL Bellen",
|
||||
"description": "Ze bellen de sectar niet goed",
|
||||
"thumbnails": {
|
||||
"default": {
|
||||
"url": "https://i.ytimg.com/vi/NgsSdGCsj5w/default.jpg",
|
||||
"width": 120,
|
||||
"height": 90
|
||||
},
|
||||
"medium": {
|
||||
"url": "https://i.ytimg.com/vi/NgsSdGCsj5w/mqdefault.jpg",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
"high": {
|
||||
"url": "https://i.ytimg.com/vi/NgsSdGCsj5w/hqdefault.jpg",
|
||||
"width": 480,
|
||||
"height": 360
|
||||
}
|
||||
},
|
||||
"channelTitle": "Ventilaar",
|
||||
"playlistId": "UUIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"position": 2,
|
||||
"resourceId": {
|
||||
"kind": "youtube#video",
|
||||
"videoId": "NgsSdGCsj5w"
|
||||
},
|
||||
"videoOwnerChannelTitle": "Ventilaar",
|
||||
"videoOwnerChannelId": "UCIhnZFYX0BKWLrQ8ZAeop3Q"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "ySAZNyJ_fxDd2nCCBU07Rh_EgIQ",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLkFlbkxxNUFYRFlZ",
|
||||
"snippet": {
|
||||
"publishedAt": "2017-02-28T13:33:28Z",
|
||||
"channelId": "UCIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"title": "Triggeredson Flicks",
|
||||
"description": "rekt",
|
||||
"thumbnails": {
|
||||
"default": {
|
||||
"url": "https://i.ytimg.com/vi/AenLq5AXDYY/default.jpg",
|
||||
"width": 120,
|
||||
"height": 90
|
||||
},
|
||||
"medium": {
|
||||
"url": "https://i.ytimg.com/vi/AenLq5AXDYY/mqdefault.jpg",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
"high": {
|
||||
"url": "https://i.ytimg.com/vi/AenLq5AXDYY/hqdefault.jpg",
|
||||
"width": 480,
|
||||
"height": 360
|
||||
}
|
||||
},
|
||||
"channelTitle": "Ventilaar",
|
||||
"playlistId": "UUIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"position": 3,
|
||||
"resourceId": {
|
||||
"kind": "youtube#video",
|
||||
"videoId": "AenLq5AXDYY"
|
||||
},
|
||||
"videoOwnerChannelTitle": "Ventilaar",
|
||||
"videoOwnerChannelId": "UCIhnZFYX0BKWLrQ8ZAeop3Q"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "t7z02W5A55Fu_FzfXNlUT3aQMjA",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLmoyRHhlOUROYzZn",
|
||||
"snippet": {
|
||||
"publishedAt": "2017-02-19T20:47:37Z",
|
||||
"channelId": "UCIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"title": "Levensmoe Flicks",
|
||||
"description": "Next: TRIGGEREDSON FLICKS",
|
||||
"thumbnails": {
|
||||
"default": {
|
||||
"url": "https://i.ytimg.com/vi/j2Dxe9DNc6g/default.jpg",
|
||||
"width": 120,
|
||||
"height": 90
|
||||
},
|
||||
"medium": {
|
||||
"url": "https://i.ytimg.com/vi/j2Dxe9DNc6g/mqdefault.jpg",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
"high": {
|
||||
"url": "https://i.ytimg.com/vi/j2Dxe9DNc6g/hqdefault.jpg",
|
||||
"width": 480,
|
||||
"height": 360
|
||||
},
|
||||
"standard": {
|
||||
"url": "https://i.ytimg.com/vi/j2Dxe9DNc6g/sddefault.jpg",
|
||||
"width": 640,
|
||||
"height": 480
|
||||
},
|
||||
"maxres": {
|
||||
"url": "https://i.ytimg.com/vi/j2Dxe9DNc6g/maxresdefault.jpg",
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
}
|
||||
},
|
||||
"channelTitle": "Ventilaar",
|
||||
"playlistId": "UUIhnZFYX0BKWLrQ8ZAeop3Q",
|
||||
"position": 4,
|
||||
"resourceId": {
|
||||
"kind": "youtube#video",
|
||||
"videoId": "j2Dxe9DNc6g"
|
||||
},
|
||||
"videoOwnerChannelTitle": "Ventilaar",
|
||||
"videoOwnerChannelId": "UCIhnZFYX0BKWLrQ8ZAeop3Q"
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"totalResults": 10,
|
||||
"resultsPerPage": 5
|
||||
}
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#playlistItemListResponse",
|
||||
"etag": "9DmIsdbWyjC5nBL55xgnBNhISIw",
|
||||
"nextPageToken": "EAAaBlBUOkNBVQ",
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "UUnaZZCecJB--PCEBhS5kzGXUao",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLnFEb25fOGxFMkcw",
|
||||
"status": {
|
||||
"privacyStatus": "public"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "HVmIbhGbHcwEQv0yxJzWygbRJLU",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLjhjZkhaMXBRb0tr",
|
||||
"status": {
|
||||
"privacyStatus": "public"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "Jz2IkO8K_3BIKNOsKEUGBqoCs8c",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLk5nc1NkR0NzajV3",
|
||||
"status": {
|
||||
"privacyStatus": "public"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "FLft-6gH5eZcYts3iGX5J5TIYfQ",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLkFlbkxxNUFYRFlZ",
|
||||
"status": {
|
||||
"privacyStatus": "public"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "L5A3qGFaWp0bdE_UZrAp-tp7elE",
|
||||
"id": "VVVJaG5aRllYMEJLV0xyUThaQWVvcDNRLmoyRHhlOUROYzZn",
|
||||
"status": {
|
||||
"privacyStatus": "public"
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"totalResults": 10,
|
||||
"resultsPerPage": 5
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
import requests
|
||||
from youtube_api_key import API_KEY
|
||||
import json
|
||||
|
||||
channel = 'UClHVl2N3jPEbkNJVx-ItQIQ' # channel uploads channel AKA UC id
|
||||
api = 'https://www.googleapis.com/youtube/v3/channels' # api endpoint
|
||||
|
||||
payload = {'id': channel, 'part': 'statistics', 'key': API_KEY[0], 'maxResults': 50} # base params
|
||||
|
||||
r = requests.get(api, params=payload).json() # request playlist and save as tuple
|
||||
|
||||
print(r)
|
|
@ -1,26 +0,0 @@
|
|||
import requests
|
||||
from youtube_api_key import API_KEY
|
||||
|
||||
channel = 'UU-lHJZR3Gqxm24_Vd_AJ5Yw' # channel uploads channel AKA UU playlist
|
||||
api = 'https://www.googleapis.com/youtube/v3/playlistItems' # api endpoint
|
||||
|
||||
payload = {'playlistId': channel, 'part': 'snippet', 'key': API_KEY[0], 'maxResults': 50, 'pageToken': ''} # base params
|
||||
|
||||
total_r = 0 # total api requests counter
|
||||
total_v = 0 # total playlist items counter
|
||||
to_list = list()
|
||||
|
||||
while payload['pageToken'] is not None: # stop when nextPageToken is none
|
||||
r = requests.get(api, params=payload).json() # request playlist and save as tuple
|
||||
total_r = total_r + 1 # increment total api requests
|
||||
|
||||
for x in r['items']: # for every item in request
|
||||
print(f"{x['snippet']['channelTitle']} | {x['snippet']['title']}") # print item channel name and video title
|
||||
to_list.append(x)
|
||||
total_v = total_v + 1 # increment total items
|
||||
|
||||
payload['pageToken'] = r.get('nextPageToken') # returns None if not exists
|
||||
|
||||
print(69*'_')
|
||||
print(f'Totaal API requests: {total_r}') # print total api requests
|
||||
print(f'Totaal videos: {total_v}') # print total playlist items
|
|
@ -0,0 +1,2 @@
|
|||
Maybe the official YouTube API might be used in the future instead of using yt-dlp.
|
||||
But that is a long term goal. Keeping the files just in case
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,4 @@
|
|||
import datetime
|
||||
|
||||
def get_utc():
|
||||
return datetime.datetime.utcnow().replace(microsecond=0)
|
|
@ -0,0 +1,47 @@
|
|||
from helpers import *
|
||||
|
||||
class Mango:
|
||||
def __init__(self, connect):
|
||||
try:
|
||||
import pymongo
|
||||
self.client = pymongo.MongoClient(connect)
|
||||
self.channels = self.client['ayta']['channels']
|
||||
self.info_json = self.client['ayta']['info_json']
|
||||
self.download_queue = self.client['ayta']['download_queue']
|
||||
except ConnectionError:
|
||||
print('MongoDB connection error')
|
||||
|
||||
def check_exists(self, vId):
|
||||
if self.info_json.count_documents({'id': vId}) >= 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_all_channels(self):
|
||||
""" Returns tuple channel ids """
|
||||
channels = []
|
||||
for channel in self.channels.find({'active': True}, {'id': 1}):
|
||||
channels.append(channel['id'])
|
||||
return tuple(channels)
|
||||
|
||||
def list_active_channels(self):
|
||||
""" Returns tuple channel ids """
|
||||
channels = []
|
||||
for channel in self.channels.find({'active': True}, {'id': 1}):
|
||||
channels.append(channel['id'])
|
||||
return tuple(channels)
|
||||
|
||||
def get_last_etag(self, channel):
|
||||
""" string of channel etag if exists or None """
|
||||
data = self.channels.find_one({'id': channel}, {'fast.etag': 1})
|
||||
try:
|
||||
return data['fast']['etag']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def update_fast_etag(self, channel, etag):
|
||||
self.channels.update_one({'id': channel}, {"$set": {"fast.etag": etag, "fast.lastrun": get_utc()}})
|
||||
return etag
|
||||
|
||||
def insert_download_queue(self, video):
|
||||
if not self.download_queue.count_documents({'id': video}) >= 1:
|
||||
return self.download_queue.insert_one({'id': video}).inserted_id
|
|
@ -0,0 +1,32 @@
|
|||
from mango import *
|
||||
from ytapiv3 import *
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mango = Mango('mongodb://root:example@192.168.66.140:27017')
|
||||
ytapi = Ytapi()
|
||||
|
||||
channels = mango.list_active_channels()
|
||||
|
||||
for channel in channels:
|
||||
lastetag = mango.get_last_etag(channel)
|
||||
lastetag = None
|
||||
try:
|
||||
if not lastetag:
|
||||
data = ytapi.get_all_video_ids(channel)
|
||||
#data = ytapi.get_last_50_videos(channel, lastetag)
|
||||
else:
|
||||
data = ytapi.get_last_50_videos(channel)
|
||||
except ytapi.noChange:
|
||||
print(f"{channel} had no change since last run, skipping...")
|
||||
continue
|
||||
|
||||
print(f"Updating channel etag: {channel}")
|
||||
#mango.update_fast_etag(channel, data['etag'])
|
||||
|
||||
for x in data:
|
||||
if mango.check_exists(x):
|
||||
continue
|
||||
|
||||
print(f"{x} adding to queue")
|
||||
mango.insert_download_queue(x)
|
|
@ -0,0 +1,58 @@
|
|||
import requests
|
||||
|
||||
class Ytapi:
|
||||
def __init__(self, api_key='AIzaSyDQ0kP7yoX5yVYJs8WlnVQ8qCT03N8xxxx'):
|
||||
self.api = 'https://www.googleapis.com/youtube/v3/playlistItems'
|
||||
self.key = api_key
|
||||
|
||||
class noChange(Exception):
|
||||
pass
|
||||
|
||||
def convert_to_uu(self, channelId):
|
||||
if channelId[0:2] == 'UC':
|
||||
return f'UU{channelId[2:]}'
|
||||
else:
|
||||
return channelId
|
||||
|
||||
def get_all_video_ids(self, channelId):
|
||||
channelId = self.convert_to_uu(channelId)
|
||||
payload = {'playlistId': channelId, 'part': 'snippet', 'key': self.key, 'maxResults': 50, 'pageToken': ''}
|
||||
headers = {'Accept-Encoding': 'gzip'}
|
||||
vIds = []
|
||||
|
||||
while payload['pageToken'] is not None:
|
||||
r = requests.get(self.api, params=payload, headers=headers)
|
||||
|
||||
if r.status_code != 200:
|
||||
raise Exception(r.text)
|
||||
|
||||
data = r.json()
|
||||
for x in data['items']:
|
||||
vIds.append(x['snippet']['resourceId']['videoId'])
|
||||
|
||||
payload['pageToken'] = data.get('nextPageToken')
|
||||
return tuple(vIds)
|
||||
|
||||
def get_last_50_videos(self, channelId, etag=''):
|
||||
"""
|
||||
Returns dict with key etag with string and key videoIds with tuple of video ids
|
||||
Or raises ytapiv3.Ytapi.noChange if request has not changed since last etag
|
||||
"""
|
||||
channelId = self.convert_to_uu(channelId)
|
||||
payload = {'playlistId': channelId, 'part': 'snippet', 'key': self.key, 'maxResults': 50}
|
||||
headers = {'Accept-Encoding': 'gzip', 'If-None-Match': etag}
|
||||
vIds = []
|
||||
|
||||
r = requests.get(self.api, params=payload, headers=headers)
|
||||
|
||||
if r.status_code == 304:
|
||||
raise self.noChange('Nothing changed since last request')
|
||||
|
||||
if r.status_code != 200:
|
||||
raise Exception(r.text)
|
||||
|
||||
data = r.json()
|
||||
for x in data['items']:
|
||||
vIds.append(x['snippet']['resourceId']['videoId'])
|
||||
|
||||
return {'etag': data.get('etag'), 'videoIds': tuple(vIds)}
|
|
@ -0,0 +1,4 @@
|
|||
flask
|
||||
flask-caching
|
||||
pymongo
|
||||
yt-dlp
|
Loading…
Reference in New Issue