Compare commits

...

2 Commits

Author SHA1 Message Date
Ventilaar
360b80343f Quick change to load slugified files 2024-03-22 00:10:41 +01:00
Ventilaar
45348d2cf5 Sort orphaned videos by added date, add queue functionality 2024-03-21 15:22:56 +01:00
11 changed files with 311 additions and 30 deletions

View File

@@ -39,7 +39,7 @@ def create_app(test_config=None):
from .blueprints import search from .blueprints import search
from .blueprints import channel from .blueprints import channel
from .blueprints import auth from .blueprints import auth
from .blueprints import websub from .blueprints import api
app.register_blueprint(watch.bp) app.register_blueprint(watch.bp)
app.register_blueprint(index.bp) app.register_blueprint(index.bp)
@@ -47,6 +47,6 @@ def create_app(test_config=None):
app.register_blueprint(search.bp) app.register_blueprint(search.bp)
app.register_blueprint(channel.bp) app.register_blueprint(channel.bp)
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
app.register_blueprint(websub.bp) app.register_blueprint(api.bp)
return app return app

View File

@@ -5,7 +5,7 @@ from ..dlp import checkChannelId, getChannelInfo
from ..decorators import login_required from ..decorators import login_required
from ..tasks import subscribe_websub_callback, unsubscribe_websub_callback from ..tasks import subscribe_websub_callback, unsubscribe_websub_callback
from datetime import datetime from datetime import datetime
import requests from secrets import token_urlsafe
bp = Blueprint('admin', __name__, url_prefix='/admin') bp = Blueprint('admin', __name__, url_prefix='/admin')
@@ -150,6 +150,50 @@ def reports():
return render_template('admin/reports.html', reports=reports) return render_template('admin/reports.html', reports=reports)
@bp.route('/posters', methods=['GET', 'POST'])
@login_required
def posters():
if request.method == 'POST':
task = request.form.get('task', None)
value = request.form.get('value', None)
if task == 'add-endpoint':
description = request.form.get('description', None)
if not description or len(description) <= 7:
flash('Description must be at least 8 characters long')
if value and len(value) >= 12:
get_nosql().poster_newEndpoint(value, description)
flash(f'Created endpoint ID: {value}')
else:
value = token_urlsafe(16)
get_nosql().poster_newEndpoint(value, description)
flash(f'Created endpoint ID: {value}')
elif task == 'retire':
get_nosql().poster_retireEndpoint(value)
flash(f'Endpoint retired: {value}')
elif task == 'clean-retired':
get_nosql().poster_cleanRetired()
flash(f'Cleaned retired endpoints')
elif task == 'manual-queue':
get_nosql().poster_insertQueue('manual', value)
flash(f'Added to queue: {value}')
elif task == 'delete-queue':
get_nosql().poster_deleteQueue(value)
flash(f'Deleted from queue: {value}')
return redirect(url_for('admin.posters'))
endpoints = get_nosql().poster_getEndpoints()
queue = get_nosql().poster_getQueue()
return render_template('admin/posters.html', endpoints=endpoints, queue=queue)
@bp.route('/files', methods=['GET', 'POST']) @bp.route('/files', methods=['GET', 'POST'])
@login_required @login_required
def files(): def files():

View File

@@ -2,10 +2,12 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
from ..nosql import get_nosql from ..nosql import get_nosql
from ..extensions import caching, caching_unless from ..extensions import caching, caching_unless
bp = Blueprint('websub', __name__, url_prefix='/websub') import re
@bp.route('/c/<cap>', methods=['GET', 'POST']) bp = Blueprint('api', __name__, url_prefix='/api')
def callback(cap):
@bp.route('/websub/<cap>', methods=['GET', 'POST'])
def websub(cap):
if request.method == 'GET': if request.method == 'GET':
topic = request.args.get('hub.topic') topic = request.args.get('hub.topic')
challenge = request.args.get('hub.challenge') challenge = request.args.get('hub.challenge')
@@ -35,4 +37,30 @@ def callback(cap):
return abort(500) return abort(500)
return '', 202 return '', 202
return abort(404) return abort(404)
@bp.route('/poster/<cap>', methods=['POST'])
def poster(cap):
# if endpoint does not exist
if not get_nosql().poster_isActive(cap):
return abort(404)
videoId = request.form.get('v')
# if request is not valid
if not videoId:
return abort(400)
# if requested string is not correct
if not re.match(r"^[a-zA-Z0-9_-]{11}$", videoId):
return abort(422)
# if given string is already in the archive
if get_nosql().check_exists(videoId):
return abort(409)
# try to insert
if get_nosql().poster_insertQueue(cap, videoId):
return '', 202
else:
return abort(409)

View File

@@ -41,8 +41,10 @@ def channel(channelId):
def orphaned(): def orphaned():
videoIds = get_nosql().get_orphaned_videos() videoIds = get_nosql().get_orphaned_videos()
videos = {} videos = []
for videoId in videoIds: for videoId in videoIds:
videos[videoId] = get_nosql().get_video_info(videoId, limited=True) videos.append(get_nosql().get_video_info(videoId, limited=True))
videos = sorted(videos, key=lambda x: x.get('epoch', 0), reverse=True)
return render_template('channel/orphaned.html', videos=videos) return render_template('channel/orphaned.html', videos=videos)

View File

@@ -51,12 +51,13 @@ class Mango:
self.db = self.client['ayta'] self.db = self.client['ayta']
self.channels = self.db['channels'] self.channels = self.db['channels']
self.info_json = self.db['info_json'] self.info_json = self.db['info_json']
self.download_queue = self.db['download_queue'] self.posters_queue = self.db['posters_queue']
self.run_log = self.db['run_log'] self.run_log = self.db['run_log']
self.channel_log = self.db['channel_log'] self.channel_log = self.db['channel_log']
self.websub_callbacks = self.db['websub_callbacks'] self.websub_callbacks = self.db['websub_callbacks']
self.websub_data = self.db['websub_data'] self.websub_data = self.db['websub_data']
self.reports = self.db['reports'] self.reports = self.db['reports']
self.posters_endpoints = self.db['posters_endpoints']
self.ensure_indexes() self.ensure_indexes()
#self.clean_info_json() #self.clean_info_json()
@@ -112,13 +113,9 @@ class Mango:
stats['videos'] = self.info_json.count_documents({}) stats['videos'] = self.info_json.count_documents({})
stats['channels'] = self.channels.count_documents({}) stats['channels'] = self.channels.count_documents({})
stats['queue'] = self.download_queue.count_documents({}) stats['queue'] = self.posters_queue.count_documents({})
return stats return stats
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 search_videos(self, query): def search_videos(self, query):
# search the index for the requested query. return limited keys # search the index for the requested query. return limited keys
@@ -202,7 +199,7 @@ class Mango:
def get_video_info(self, videoId, limited=False): def get_video_info(self, videoId, limited=False):
if limited: if limited:
projection = {'_id': 1, 'id': 1, 'title': 1, 'upload_date': 1, 'description': 1, 'channel_id': 1} projection = {'_id': 1, 'id': 1, 'title': 1, 'upload_date': 1, 'description': 1, 'channel_id': 1, 'epoch': 1, 'title_slug': 1}
else: else:
projection = {} projection = {}
@@ -262,7 +259,7 @@ class Mango:
########################################## ##########################################
def websub_newCallback(self, channelId): def websub_newCallback(self, channelId):
callbackId = secrets.token_hex(8) callbackId = secrets.token_urlsafe(16)
self.websub_callbacks.insert_one({'id': callbackId, 'channel': channelId, 'status': 'new', 'created_time': current_time(object=True)}) self.websub_callbacks.insert_one({'id': callbackId, 'channel': channelId, 'status': 'new', 'created_time': current_time(object=True)})
@@ -336,10 +333,60 @@ class Mango:
def websub_cleanRetired(self, days=3): def websub_cleanRetired(self, days=3):
days = self.datetime.utcnow() - self.timedelta(days=days) days = self.datetime.utcnow() - self.timedelta(days=days)
self.websub_callbacks.delete_many({'status': 'retired', 'retiring_time': {'$lt': days}}) self.websub_callbacks.delete_many({'status': 'retired', 'retired_time': {'$lt': days}})
return True return True
##########################################
# POSTER FUNCTIONS #
##########################################
def poster_newEndpoint(self, endpointId, description=''):
self.posters_endpoints.insert_one({'id': endpointId, 'description': description, 'status': 'active', 'created_time': current_time(object=True)})
return endpointId
def poster_insertQueue(self, endpointId, videoId):
# if no document exists
if not self.posters_queue.count_documents({'id': videoId}) >= 1:
self.posters_queue.insert_one({'id': videoId, 'endpoint': endpointId, 'created_time': current_time(object=True), 'status': 'queued'}).inserted_id
return True
# key already in queue
return False
def poster_deleteQueue(self, videoId):
if self.posters_queue.delete_one({'id': videoId}):
return True
return False
def poster_retireEndpoint(self, endpointId):
return self.posters_endpoints.update_one({'id': endpointId}, {'$set': {'status': 'retired', 'retired_time': current_time(object=True)}})
def poster_isActive(self, endpointId):
status = self.posters_endpoints.find_one({'id': endpointId}, {'status': 1})
if not status:
return False
status = status.get('status')
if status == 'active':
return True
return False
def poster_getEndpoints(self):
return self.posters_endpoints.find({})
def poster_getQueue(self):
return self.posters_queue.find({})
def poster_cleanRetired(self, days=3):
days = self.datetime.utcnow() - self.timedelta(days=days)
self.posters_endpoints.delete_many({'status': 'retired', 'retired_time': {'$lt': days}})
return True
########################################## ##########################################
# HELPER FUNCTIONS # # HELPER FUNCTIONS #

View File

@@ -10,7 +10,7 @@ def subscribe_websub_callback(channelId):
url = 'https://pubsubhubbub.appspot.com/subscribe' url = 'https://pubsubhubbub.appspot.com/subscribe'
data = { data = {
'hub.callback': f'https://{current_app.config["DOMAIN"]}/websub/c/{callbackId}', 'hub.callback': f'https://{current_app.config["DOMAIN"]}/api/websub//{callbackId}',
'hub.topic': f'https://www.youtube.com/xml/feeds/videos.xml?channel_id={channelId}', 'hub.topic': f'https://www.youtube.com/xml/feeds/videos.xml?channel_id={channelId}',
'hub.verify': 'async', 'hub.verify': 'async',
'hub.mode': 'subscribe', 'hub.mode': 'subscribe',
@@ -32,7 +32,7 @@ def unsubscribe_websub_callback(callbackId, channelId):
from .nosql import get_nosql from .nosql import get_nosql
url = 'https://pubsubhubbub.appspot.com/subscribe' url = 'https://pubsubhubbub.appspot.com/subscribe'
data = {'hub.callback': f'https://{current_app.config["DOMAIN"]}/websub/c/{callbackId}', data = {'hub.callback': f'https://{current_app.config["DOMAIN"]}/api/websub/{callbackId}',
'hub.topic': f'https://www.youtube.com/xml/feeds/videos.xml?channel_id={channelId}', 'hub.topic': f'https://www.youtube.com/xml/feeds/videos.xml?channel_id={channelId}',
'hub.verify': 'async', 'hub.verify': 'async',
'hub.mode': 'unsubscribe' 'hub.mode': 'unsubscribe'

View File

@@ -65,5 +65,15 @@
</div> </div>
</a> </a>
</div> </div>
<div class="col s6 l4 m-4">
<a href="{{ url_for('admin.posters') }}">
<div class="card black-text">
<div class="card-content">
<span class="card-title">Posters</span>
<p class="grey-text">User extension posters</p>
</div>
</div>
</a>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,150 @@
{% extends 'material_base.html' %}
{% block title %}Posters administration page{% endblock %}
{% block description %}Posters administration page of the AYTA system{% endblock %}
{% block content %}
<div class="row">
<div class="col s12 l11">
<h4>Posters administration page</h4>
</div>
<div class="col s12 l1 m-5">
<form method="POST">
<input title="Prunes all deleted endpoints, but keeps last 3 days" type="submit" value="clean-retired" name="task">
</form>
</div>
</div>
<div class="divider"></div>
<div class="row">
<div class="col s12">
<h5>Poster 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">Create new endpoint</span>
<form method="post">
<div class="row">
<div class="col s12 input-field">
<input placeholder="Custom endpoint" name="value" type="text" class="validate" minlength="12">
<span class="supporting-text">Leaving this empty will create a random secure string</span>
</div>
<div class="col s12 input-field">
<input placeholder="Description" name="description" type="text" class="validate" minlength="8" maxlength="64" required>
<span class="supporting-text">Description for the endpoint for better administration</span>
</div>
<button class="btn mt-4" type="submit" name="task" value="add-endpoint">Create</button>
</div>
</form>
</div>
</div>
</div>
<div class="col s12 l4 m-4">
<div class="card">
<div class="card-content">
<span class="card-title">Queue manually</span>
<form method="post">
<div class="row">
<div class="col s12 input-field">
<input placeholder="Youtube video ID" name="value" type="text" class="validate" minlength="11" maxlength="11" required>
<span class="supporting-text">Must be a valid Youtube video ID</span>
</div>
<div class="col s12 mt-5 input-field">
<div class="switch">
<label>Queue<input type="checkbox" value="direct" name="value" disabled><span class="lever"></span>Direct</label>
<span class="supporting-text">Queue up or start directly</span>
</div>
</div>
<button class="btn mt-4" type="submit" name="task" value="manual-queue">Queue</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="divider"></div>
<div class="row">
<div class="col s6 l9">
<h5>Registered endpoints</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">
<div class="col s12">
<table class="striped highlight responsive-table">
<thead>
<tr>
<th>Actions</th>
<th>id</th>
<th>description</th>
<th>status</th>
<th>created_time</th>
<th>retired_time</th>
</tr>
</thead>
<tbody>
{% for endpoint in endpoints %}
<tr class="filterable">
<td>
<form method="post">
<input type="text" value="{{ endpoint.get('id') }}" name="value" hidden>
<button class="btn-small waves-effect waves-light" type="submit" name="task" value="retire" title="Retire endpoint" {% if endpoint.get('status') != 'active' %}disabled{% endif %}>🗑️</button>
</form>
</td>
<td>{{ endpoint.get('id') }}</td>
<td>{{ endpoint.get('description') }}</td>
<td>{{ endpoint.get('status') }}</td>
<td>{{ endpoint.get('created_time') }}</td>
<td>{{ endpoint.get('retired_time') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="divider"></div>
<div class="row">
<div class="col s6 l9">
<h5>Queued ID's</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">
<div class="col s12">
<table class="striped highlight responsive-table">
<thead>
<tr>
<th>Actions</th>
<th>id</th>
<th>endpoint</th>
<th>status</th>
<th>created_time</th>
</tr>
</thead>
<tbody>
{% for id in queue %}
<tr class="filterable">
<td>
<form method="post">
<input type="text" value="{{ id.get('id') }}" name="value" hidden>
<button class="btn-small waves-effect waves-light" type="submit" name="task" value="delete-queue" title="Delete from queue" {% if id.get('status') != 'queued' %}disabled{% endif %}>🗑️</button>
</form>
</td>
<td>{{ id.get('id') }}</td>
<td>{{ id.get('endpoint') }}</td>
<td>{{ id.get('status') }}</td>
<td>{{ id.get('created_time') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -25,7 +25,7 @@
<div class="card medium black-text"> <div class="card medium black-text">
<a href="{{ url_for('watch.base') }}?v={{ video.get('id') }}"> <a href="{{ url_for('watch.base') }}?v={{ video.get('id') }}">
<div class="card-image"> <div class="card-image">
<img loading="lazy" src="https://archive.ventilaar.net/videos/automatic/{{ video.get('channel_id') }}/{{ video.get('id') }}/{{ video.get('title') }}.jpg"> <img loading="lazy" src="https://archive.ventilaar.net/videos/automatic/{{ video.get('channel_id') }}/{{ video.get('id') }}/{{ video.get('title_slug') }}.jpg">
</div> </div>
</a> </a>
<div class="card-content activator"> <div class="card-content activator">

View File

@@ -12,7 +12,7 @@
<div class="row"> <div class="row">
<div class="col s6 l9"> <div class="col s6 l9">
<h5>Orphaned videos</h5> <h5>Orphaned videos</h5>
<p>Videos in the archive which do not have a permanent channel linked. There is a high chance that the videos are manually downloaded.</p> <p>Videos in the archive which do not have a permanent channel linked. There is a high chance that the videos are manually downloaded. Sorted by last added.</p>
</div> </div>
<div class="col s6 l3 m-4 input-field"> <div class="col s6 l3 m-4 input-field">
<input id="filter_query" type="text"> <input id="filter_query" type="text">
@@ -23,18 +23,18 @@
{% for video in videos %} {% for video in videos %}
<div class="col s6 l4 m-4 filterable"> <div class="col s6 l4 m-4 filterable">
<div class="card medium black-text"> <div class="card medium black-text">
<a href="{{ url_for('watch.base') }}?v={{ video }}"> <a href="{{ url_for('watch.base') }}?v={{ video.get('id') }}">
<div class="card-image"> <div class="card-image">
<img loading="lazy" src="https://archive.ventilaar.net/videos/automatic/{{ videos[video].get('channel_id') }}/{{ videos[video].get('id') }}/{{ videos[video].get('title') }}.jpg"> <img loading="lazy" src="https://archive.ventilaar.net/videos/automatic/{{ video.get('channel_id') }}/{{ video.get('id') }}/{{ video.get('title') }}.jpg">
</div> </div>
</a> </a>
<div class="card-content activator"> <div class="card-content activator">
<span class="card-title">{{ videos[video].get('title') }}</span> <span class="card-title">{{ video.get('title') }}</span>
<p class="grey-text">{{ videos[video].get('id') }} | {{ videos[video].get('upload_date')|pretty_time }}</p> <p class="grey-text">{{ video.get('id') }} | {{ video.get('upload_date')|pretty_time }}</p>
</div> </div>
<div class="card-reveal"> <div class="card-reveal">
<span class="card-title truncate">{{ videos[video].get('title') }}</span> <span class="card-title truncate">{{ video.get('title') }}</span>
<p style="white-space: pre-wrap;">{{ videos[video].get('description') }}</p> <p style="white-space: pre-wrap;">{{ video.get('description') }}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -31,8 +31,8 @@
<div class="row"> <div class="row">
<div class="col s12 center-align"> <div class="col s12 center-align">
<video controls class="responsive-video"> <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_slug') }}.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"> <source src="https://archive.ventilaar.net/videos/automatic/{{ render.get('info').get('channel_id') }}/{{ render.get('info').get('id') }}/{{ render.get('info').get('title_slug') }}.webm">
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
</div> </div>