base upload of offline files

This commit is contained in:
Ventilaar 2024-02-24 22:22:59 +01:00
parent 8f48982077
commit 645beccad3
No known key found for this signature in database
65 changed files with 1747 additions and 555 deletions

View File

@ -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!
_______________________________________________________________________

View File

@ -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

13
ad-hoc-test.py Normal file
View File

@ -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))

35
ayta/__init__.py Normal file
View File

@ -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

100
ayta/blueprints/admin.py Normal file
View File

@ -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)

View File

@ -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)

12
ayta/blueprints/index.py Normal file
View File

@ -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')

16
ayta/blueprints/search.py Normal file
View File

@ -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
ayta/blueprints/video.py Normal file
View File

27
ayta/blueprints/watch.py Normal file
View File

@ -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
ayta/db.py Normal file
View File

36
ayta/dlp.py Normal file
View File

@ -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])

3
ayta/filters.py Normal file
View File

@ -0,0 +1,3 @@
def pretty_time(seconds):
minutes, seconds = divmod(seconds, 60)
return '{:3}:{:02} minutes'.format(int(minutes), int(seconds))

186
ayta/nosql.py Normal file
View File

@ -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')

50
ayta/s3.py Normal file
View File

@ -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

View File

@ -0,0 +1,9 @@
body {
display: flex;
min-height: 100vh;
flex-direction: column;
}
main {
flex: 1 0 auto;
}

6
ayta/static/css/materialize.min.css vendored Normal file

File diff suppressed because one or more lines are too long

BIN
ayta/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

BIN
ayta/static/icons.woff Normal file

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

8
ayta/static/js/custom.js Normal file
View File

@ -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

7
ayta/static/js/materialize.min.js vendored Normal file

File diff suppressed because one or more lines are too long

44
ayta/static/master.js Normal file
View File

@ -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();
});

BIN
ayta/static/nothumb.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

406
ayta/static/style.css Normal file
View File

@ -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;
}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

23
ayta/templates/base.html Normal file
View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

18
ayta/templates/index.html Normal file
View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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

View File

@ -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"
}
}
}
]
}

View File

@ -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"
}
}
}
]
}

View File

@ -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": {}
}
]
}

View File

@ -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"
}
]
}

View File

@ -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"
}
]
}

View File

@ -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": ""
}
}
}
]
}

View File

@ -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"
}
}
]
}

View File

@ -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
}
}
]
}

View File

@ -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"
]
}
}
]
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,4 @@
import datetime
def get_utc():
return datetime.datetime.utcnow().replace(microsecond=0)

View File

@ -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

View File

@ -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)

View File

@ -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)}

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
flask
flask-caching
pymongo
yt-dlp

4
run.py Normal file
View File

@ -0,0 +1,4 @@
import ayta
if __name__ == '__main__':
ayta.create_app().run(debug=True, host="0.0.0.0")