You've already forked amazing-ytdlp-archive
							
							Compare commits
	
		
			6 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | f90b0bdc42 | ||
|   | 1be9729720 | ||
|   | 1918a03e05 | ||
|   | ed4f8b03eb | ||
|   | 7266a437d1 | ||
|   | 360b80343f | 
							
								
								
									
										82
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										82
									
								
								README.md
									
									
									
									
									
								
							| @@ -6,24 +6,64 @@ current cronjob yt-dlp archive service. | ||||
| Partially inspired by [hobune](https://github.com/rebane2001/hobune). While that project is amazingby 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. | ||||
| Having over 250k videos, scaling the current cronjob yt-dlp archive task is just really hard. Filetypes change, things get partially downloaded and such. | ||||
| Partially yt-dlp is to blame because it's a package that needs to change all the time. But with this some changes are not accounted for. | ||||
| yt-dlp will still do the downloads. But a flask frontend will be developed to make all downloaded videos easily indexable. | ||||
| For it to be quick (unlike hobune) a database has to be implemented. This could get solved by a static site generator type of software, but that is not my choice. | ||||
|  | ||||
| The whole software package will use postgresql as a data backend and celery as background tasks. | ||||
| Currently development however is using mongodb just because it's easy. | ||||
|  | ||||
| ## How it works currently(legacy) | ||||
| In the legacy folder you will find files that are currently in my archive project. How it works is | ||||
| that I have a cronjob running every 6 hours what then runs yt-dlp with a config file. In that config | ||||
| that I have a cronjob running every 24 hours what then runs yt-dlp with a config file. In that config | ||||
| file a channel list contains all the channels that yt-dlp needs to update. If a new video has been | ||||
| uploaded, yt-dlp will automatically download a 720p version of the video, all subtitles at that time | ||||
| (rip community captions, will not forget you) and a json file with all the rest of the metadata. Oh  | ||||
| and also the thumbnail. | ||||
|  | ||||
| This works. But is very slow and uses lots of "API" calls to youtube, which will sometimes will get | ||||
| the IP blocked. This needs to be overhauled. | ||||
| the IP blocked. This is why full channel upload pages are not downloaded anymore, I have limited to first 50 videos. | ||||
|  | ||||
| ## Goals | ||||
| Some goals have been set up which will prioritise functionality for the software package. | ||||
| The starting status is that info.json files of videos are loaded into the mongodb database on which flask | ||||
| will generate a page for channels and videos to load. But this has major limitations which will not be described right now | ||||
| but will be reflected in the goals. | ||||
|  | ||||
| ### Stage 1 | ||||
| Tasks which have to be finished before the GUI frontend is usable as a manager and user in no perticular order. | ||||
| - [x] Have videos and channels listed on a page | ||||
| - [x] Have a secured admin page where the database can be managed | ||||
| - [x] Have working video streaming | ||||
| - [x] CI/CD pipeline for quicker deployment | ||||
| - [x] Add caching to speed up pages | ||||
| - [x] Add ratelimiting for expensive pages | ||||
| - [x] Ability to show cronjob logs to easily troubleshoot | ||||
|  | ||||
| ### Stage 2 | ||||
| Extra functionality for further development of features. | ||||
| - [x] Fix video titles on disk with slugs | ||||
| - [x] Working search functionality | ||||
| - [x] Video reporting functionality | ||||
| - [x] Ability (for external applications) to queue up video ids for download | ||||
| - [x] Add websub requesting and receiving ability. (not fully usable yet without celery tasks) | ||||
| - [] OIDC or Webauthn logins instead of static argon2 passwords | ||||
|  | ||||
| ### Stage 3 | ||||
| Mainly focused on retiring the cronjob based scripts and moving it to celery based tasks | ||||
| - [] manage videos by ID's instead of per channel basis | ||||
| - [] download videos from queue | ||||
| - [] Manage websub callbacks | ||||
|  | ||||
| ### Stage 4 | ||||
| Mongodb finally has it's limitations. | ||||
| - [] Migrate to postgresql | ||||
|  | ||||
| ### Stage ... | ||||
| Since this is my flagship software which I have developed more features will be added. | ||||
| It may take some time since this is just a hobby for me. And I'm not a programmer by title. | ||||
|  | ||||
|  | ||||
| ## Things learned | ||||
| ### Video playlists | ||||
| @@ -50,26 +90,22 @@ If you swap the channel name to channel id. The folders will never change. | ||||
| ### Storage structure | ||||
| The following folder structure is pretty nice for using static scripts. The one drawback | ||||
| is that you can't search for video id's or titles. Because the search takes too long. | ||||
| 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. 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. | ||||
| This is mainly why we need a new system using a database mainly for search. | ||||
|  | ||||
| The following structure is easily scaleable and usable in a object storage format. | ||||
| ``` | ||||
| -| data | ||||
|  | - videos | ||||
|    | - 128bit_random_id.mp4 | ||||
|  | - subtitles | ||||
|    | - same_random_id_EN.srt | ||||
|    | - same_random_id_DE.srt | ||||
|  | - thumbnails | ||||
|     | - 128bit_random_id.jpg | ||||
| ./videos/{channel_id}/{video_id}/video-title-slug-format.info.json | ||||
| ``` | ||||
|  | ||||
| ## API things learned | ||||
| ### YouTube push notifications in API form exist | ||||
| Using the pubsubhubbub service provided by Google we will implement downloading videos based on uploads. | ||||
| The API is based on WebSub which is greatly documented. | ||||
|  | ||||
| The hub will give xml+atom notifications when a video is uploaded by a channel and when a video is deleted. | ||||
| The goal is to download a video when a notification gets trough, and run a full channel sync when a video is deleted. | ||||
| This will be next to periodic full channel polling to download videos which the hub has not notified us about. | ||||
|  | ||||
| ### Etag is useful | ||||
| When we will call the api for 50 items in a playlist we also get an etag back.  | ||||
| This is a sort of hash of the returned data.  | ||||
|   | ||||
| @@ -1,20 +1,19 @@ | ||||
| def create_app(test_config=None): | ||||
|     import os, secrets | ||||
|     from flask import Flask | ||||
|     from ayta.extensions import limiter, caching, celery_init_app | ||||
|     from ayta.extensions import limiter, caching, celery_init_app, oidc | ||||
|     from werkzeug.middleware.proxy_fix import ProxyFix | ||||
|  | ||||
|     from . import filters | ||||
|  | ||||
|     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'), | ||||
|               'OIDC_PROVIDER': os.environ.get('AYTA_OIDC_PROVIDER', 'https://auth.ventilaar.nl'), | ||||
|               'OIDC_ID': os.environ.get('AYTA_OIDC_ID', 'ayta'), | ||||
|               'CACHE_TYPE': os.environ.get('AYTA_CACHETYPE', 'SimpleCache'), | ||||
|               'CACHE_DEFAULT_TIMEOUT': int(os.environ.get('AYTA_CACHETIMEOUT', 6)), | ||||
|               'SECRET_KEY': os.environ.get('AYTA_SECRETKEY', secrets.token_hex(32)), | ||||
|               'DEBUG': bool(os.environ.get('AYTA_DEBUG', False)), | ||||
|               'DOMAIN': os.environ.get('AYTA_DOMAIN', 'testing.mashallah.nl'), | ||||
|               'DOMAIN': os.environ.get('AYTA_DOMAIN', 'https://testing.mashallah.nl'), | ||||
|               'CELERY': dict(broker_url=str(os.environ.get('AYTA_CELERYBROKER', 'amqp://guest:guest@192.168.66.140:5672/')),  | ||||
|                              task_ignore_result=True,) | ||||
|              } | ||||
| @@ -24,6 +23,7 @@ def create_app(test_config=None): | ||||
|      | ||||
|     limiter.init_app(app) | ||||
|     caching.init_app(app) | ||||
|     oidc.init_app(app) | ||||
|     celery_init_app(app) | ||||
|  | ||||
|     app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1) | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| from flask import Blueprint, render_template, request, redirect, url_for, flash | ||||
| from ..nosql import get_nosql | ||||
| from ..s3 import get_s3 | ||||
| from ..dlp import checkChannelId, getChannelInfo | ||||
| from ..decorators import login_required | ||||
| from ..tasks import subscribe_websub_callback, unsubscribe_websub_callback | ||||
| @@ -192,10 +191,30 @@ def posters(): | ||||
|     | ||||
|     return render_template('admin/posters.html', endpoints=endpoints, queue=queue) | ||||
|  | ||||
|  | ||||
|  | ||||
| @bp.route('/files', methods=['GET', 'POST']) | ||||
| @bp.route('/users', methods=['GET', 'POST']) | ||||
| @login_required | ||||
| def files(): | ||||
|     run = get_s3().list_objects() | ||||
|     return str(run) | ||||
| def users(): | ||||
|     if request.method == 'POST': | ||||
|         task = request.form.get('task', None) | ||||
|         value = request.form.get('value', None) | ||||
|          | ||||
|         if task == 'add-user': | ||||
|             alias = request.form.get('alias', None) | ||||
|             description = request.form.get('description', None) | ||||
|              | ||||
|             if value is None or alias is None: | ||||
|                 flash('Missing fields') | ||||
|                 return redirect(url_for('admin.users')) | ||||
|              | ||||
|             doc_id = get_nosql().add_user(value, alias, description) | ||||
|             flash(f'User added: {doc_id}') | ||||
|             return redirect(url_for('admin.users')) | ||||
|              | ||||
|         if task == 'delete-user': | ||||
|             get_nosql().delete_user(value) | ||||
|             flash(f'User deleted: {value}') | ||||
|             return redirect(url_for('admin.users')) | ||||
|  | ||||
|     users = get_nosql().list_all_users() | ||||
|     | ||||
|     return render_template('admin/users.html', users=users) | ||||
| @@ -1,10 +1,8 @@ | ||||
| from flask import Blueprint, redirect, url_for, render_template, request, session, flash, current_app | ||||
| from ..extensions import limiter, caching, caching_unless | ||||
| from flask import Blueprint, redirect, url_for, render_template, request, session, flash, current_app, redirect | ||||
| from ..extensions import limiter, caching, caching_unless, oidc | ||||
| from ..nosql import get_nosql | ||||
|  | ||||
| from argon2 import PasswordHasher | ||||
| from argon2.exceptions import VerifyMismatchError | ||||
|  | ||||
| corr = '$argon2id$v=19$m=65536,t=3,p=4$XzX9K2MKRrGWEf/0iHf2AA$m6Q/aHoj1/uct+8a00QTS5xVWnANeMPKVUg4P822sbM' | ||||
| from time import sleep | ||||
|  | ||||
| bp = Blueprint('auth', __name__, url_prefix='/auth') | ||||
|  | ||||
| @@ -27,7 +25,7 @@ def login(): | ||||
|         password = request.form.get('password', None) | ||||
|          | ||||
|         if current_app.config.get('DEBUG'): | ||||
|             session['username'] = 'admin' | ||||
|             session['username'] = 'DEBUG ADMIN' | ||||
|             flash('You have been logged in') | ||||
|             return redirect(request.args.get('next', url_for('admin.base'))) | ||||
|          | ||||
| @@ -35,19 +33,40 @@ def login(): | ||||
|             flash('Password was empty') | ||||
|             return redirect(url_for('auth.login')) | ||||
|          | ||||
|         try: | ||||
|             ph = PasswordHasher() | ||||
|             if ph.verify(corr, password): | ||||
|                 session['username'] = 'admin' | ||||
|                 flash('You have been logged in') | ||||
|                  | ||||
|                 return redirect(request.args.get('next', url_for('admin.base'))) | ||||
|                      | ||||
|         except VerifyMismatchError: | ||||
|         sleep(0.3) | ||||
|         flash('Wrong password') | ||||
|         return redirect(url_for('auth.login')) | ||||
|         except: | ||||
|             flash('Something went wrong') | ||||
|             return redirect(url_for('auth.login')) | ||||
|          | ||||
|     return render_template('login.html') | ||||
|  | ||||
| @bp.route('/oidc', methods=['GET']) | ||||
| def start_oidc(): | ||||
|     return redirect(oidc.generate_redirect(), code=302) | ||||
|  | ||||
| @bp.route('/callback', methods=['POST']) | ||||
| @limiter.limit('30 per day', override_defaults=False) | ||||
| @caching.cached(unless=caching_unless) | ||||
| def callback(): | ||||
|     state = request.form.get('state', None) | ||||
|     id_token = request.form.get('id_token', None) | ||||
|      | ||||
|     if request.form.get('error', None): | ||||
|         return f'We got an error from the authentication provider with the message: {request.form.get("error_description", None)}', 400 | ||||
|      | ||||
|     if state is None or id_token is None: | ||||
|         return 'Request error', 400 | ||||
|      | ||||
|     if not oidc.state_check(state): | ||||
|         return 'CSRF Error, state is not valid', 400 | ||||
|  | ||||
|     sub = oidc.check_bearer(id_token) | ||||
|      | ||||
|     if not sub: | ||||
|         return f'Invalid JWT token we got: {id_token}', 400 | ||||
|      | ||||
|     if not get_nosql().get_user(sub): | ||||
|         return f'Authentication successful, but you are not allowed to access authenticated pages. Please report this ID to the administrators if you want access: {sub}', 403 | ||||
|      | ||||
|     session['username'] = sub | ||||
|     flash('You have been logged in') | ||||
|     return redirect(request.args.get('next', url_for('admin.base'))) | ||||
| @@ -1,6 +1,5 @@ | ||||
| from flask import Blueprint, render_template, flash, url_for, redirect | ||||
| from ..nosql import get_nosql | ||||
| from ..s3 import get_s3 | ||||
| from ..extensions import caching, caching_unless | ||||
|  | ||||
| bp = Blueprint('channel', __name__, url_prefix='/channel') | ||||
| @@ -8,12 +7,15 @@ bp = Blueprint('channel', __name__, url_prefix='/channel') | ||||
| @bp.route('') | ||||
| @caching.cached(unless=caching_unless) | ||||
| def base(): | ||||
|     channels = {} | ||||
|     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) | ||||
|         channel = get_nosql().get_channel_info(channelId) | ||||
|         channel['video_count'] = get_nosql().get_channel_videos_count(channelId) | ||||
|         channels.append(channel) | ||||
|          | ||||
|     channels = sorted(channels, key=lambda x: x.get('added_date'), reverse=True) | ||||
|          | ||||
|     return render_template('channel/index.html', channels=channels) | ||||
|      | ||||
| @@ -48,3 +50,16 @@ def orphaned(): | ||||
|     videos = sorted(videos, key=lambda x: x.get('epoch', 0), reverse=True) | ||||
|  | ||||
|     return render_template('channel/orphaned.html', videos=videos) | ||||
|      | ||||
| @bp.route('/recent') | ||||
| @caching.cached(unless=caching_unless) | ||||
| def recent(): | ||||
|     videoIds = get_nosql().get_recent_videos() | ||||
|      | ||||
|     videos = [] | ||||
|     for videoId in videoIds: | ||||
|         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/recent.html', videos=videos) | ||||
| @@ -3,10 +3,12 @@ from flask_limiter.util import get_remote_address | ||||
|  | ||||
| from flask_caching import Cache | ||||
|  | ||||
| from flask import Flask, request, session | ||||
|  | ||||
| from celery import Celery, Task | ||||
|  | ||||
| from .oidc import OIDC | ||||
|  | ||||
| from flask import Flask, request, session | ||||
|  | ||||
| def celery_init_app(app: Flask) -> Celery: | ||||
|     class FlaskTask(Task): | ||||
|         def __call__(self, *args: object, **kwargs: object) -> object: | ||||
| @@ -46,3 +48,4 @@ limiter = Limiter( | ||||
|      | ||||
| caching = Cache() | ||||
|  | ||||
| oidc = OIDC() | ||||
| @@ -3,8 +3,7 @@ import pymongo | ||||
| import secrets | ||||
| from bson.objectid import ObjectId | ||||
|  | ||||
| from flask import current_app | ||||
| from flask import g | ||||
| from flask import current_app, g | ||||
|  | ||||
| from .filters import current_time | ||||
|  | ||||
| @@ -19,20 +18,6 @@ def get_nosql(): | ||||
|  | ||||
|     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                    # | ||||
| ########################################## | ||||
| @@ -58,9 +43,9 @@ class Mango: | ||||
|         self.websub_data = self.db['websub_data'] | ||||
|         self.reports = self.db['reports'] | ||||
|         self.posters_endpoints = self.db['posters_endpoints'] | ||||
|         self.users = self.db['users'] | ||||
|              | ||||
|         self.ensure_indexes() | ||||
|         #self.clean_info_json() | ||||
|     | ||||
|     def ensure_indexes(self): | ||||
|         required = { | ||||
| @@ -132,6 +117,27 @@ class Mango: | ||||
|         results = sorted(results, key=lambda x: x.get('score'), reverse=True)[:20] | ||||
|         return tuple(results) | ||||
|      | ||||
|     ########################################## | ||||
|     #              user operations           # | ||||
|     ########################################## | ||||
|      | ||||
|     def list_all_users(self): | ||||
|         return self.users.find({}) | ||||
|      | ||||
|     def add_user(self, sub, alias, description=None): | ||||
|         return self.users.insert_one({'sub': sub, 'alias': alias, 'description': description}).inserted_id | ||||
|          | ||||
|     def delete_user(self, sub): | ||||
|         self.users.delete_one({'sub': sub}) | ||||
|          | ||||
|     def get_user(self, sub): | ||||
|         """ Returns True if sub exists, otherwise False """ | ||||
|         if self.users.count_documents({'sub': sub}) >= 1: | ||||
|             return True | ||||
|              | ||||
|         return False | ||||
|          | ||||
|      | ||||
|     ########################################## | ||||
|     #            channel operations          # | ||||
|     ########################################## | ||||
| @@ -197,9 +203,20 @@ class Mango: | ||||
|          | ||||
|         return tuple(ids) | ||||
|      | ||||
|     def get_recent_videos(self, count=99): | ||||
|         """ Returns a SET of YouTube video ID's which have been added last to the info_json collection """ | ||||
|         result = self.info_json.find({}, {'_id': 0, 'id': 1}, sort=[('_id', pymongo.DESCENDING)]).limit(count) | ||||
|  | ||||
|         ids = [] | ||||
|          | ||||
|         for id in result: | ||||
|             ids.append(id['id']) | ||||
|  | ||||
|         return tuple(ids) | ||||
|          | ||||
|     def get_video_info(self, videoId, limited=False): | ||||
|         if limited: | ||||
|             projection = {'_id': 1, 'id': 1, 'title': 1, 'upload_date': 1, 'description': 1, 'channel_id': 1, 'epoch': 1} | ||||
|             projection = {'_id': 1, 'id': 1, 'title': 1, 'upload_date': 1, 'description': 1, 'channel_id': 1, 'uploader': 1, 'epoch': 1, 'title_slug': 1} | ||||
|         else: | ||||
|             projection = {} | ||||
|          | ||||
|   | ||||
							
								
								
									
										125
									
								
								ayta/oidc.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								ayta/oidc.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
| class OIDC(): | ||||
|     def __init__(self, app=None): | ||||
|         self.states = {} | ||||
|         self.nonces = {} | ||||
|      | ||||
|         if app is not None: | ||||
|             self.init_app(app) | ||||
|  | ||||
|     def init_app(self, app): | ||||
|         import requests | ||||
|         import jwt | ||||
|  | ||||
|         config = app.config.copy() | ||||
|  | ||||
|         self.client_id = config['OIDC_ID'] | ||||
|         self.provider = config['OIDC_PROVIDER'] | ||||
|         self.domain = config['DOMAIN'] | ||||
|          | ||||
|         if self.provider[:8] != 'https://' or self.provider[-1] == '/': | ||||
|             print('Incorrect OIDC provider URI', flush=True) | ||||
|             exit() | ||||
|              | ||||
|         configuration = requests.get(f'{self.provider}/.well-known/openid-configuration').json() | ||||
|          | ||||
|         jwks_uri = configuration.get('jwks_uri') | ||||
|         self.authorize_uri = configuration.get('authorization_endpoint') | ||||
|          | ||||
|         self.jwks_manager = jwt.PyJWKClient(jwks_uri) | ||||
|  | ||||
|     ################################# | ||||
|  | ||||
|     def state_maintenance(self): | ||||
|         from datetime import datetime | ||||
|          | ||||
|         pivot = datetime.now().timestamp() - 120 | ||||
|          | ||||
|         expired_states = [state for state, timestamp in self.states.items() if timestamp <= pivot] | ||||
|          | ||||
|         for state in expired_states: | ||||
|             del self.states[state] | ||||
|  | ||||
|     def state_gen(self): | ||||
|         import secrets | ||||
|         from datetime import datetime | ||||
|          | ||||
|         self.state_maintenance() | ||||
|          | ||||
|         state = secrets.token_urlsafe(8) | ||||
|         timestamp = datetime.now().timestamp() | ||||
|  | ||||
|         self.states[state] = timestamp | ||||
|  | ||||
|         return state | ||||
|          | ||||
|     def state_check(self, state): | ||||
|         self.state_maintenance() | ||||
|  | ||||
|         if state in self.states: | ||||
|             del self.states[state] | ||||
|             return True | ||||
|              | ||||
|         return False | ||||
|          | ||||
|     ################################# | ||||
|      | ||||
|     def nonce_maintenance(self): | ||||
|         from datetime import datetime | ||||
|          | ||||
|         pivot = datetime.now().timestamp() - 120 | ||||
|          | ||||
|         expired_nonces = [nonce for nonce, timestamp in self.nonces.items() if timestamp <= pivot] | ||||
|          | ||||
|         for nonce in expired_nonces: | ||||
|             del self.nonces[nonce] | ||||
|  | ||||
|     def nonce_gen(self): | ||||
|         import secrets | ||||
|         from datetime import datetime | ||||
|          | ||||
|         self.nonce_maintenance() | ||||
|          | ||||
|         nonce = secrets.token_urlsafe(8) | ||||
|         timestamp = datetime.now().timestamp() | ||||
|  | ||||
|         self.nonces[nonce] = timestamp | ||||
|  | ||||
|         return nonce | ||||
|          | ||||
|     def nonce_check(self, nonce): | ||||
|         self.nonce_maintenance() | ||||
|  | ||||
|         if nonce in self.nonces: | ||||
|             del self.nonces[nonce] | ||||
|             return True | ||||
|              | ||||
|         return False | ||||
|      | ||||
|     ################################# | ||||
|      | ||||
|     def generate_redirect(self): | ||||
|         return str(f'{self.authorize_uri}' | ||||
|                    '?response_mode=form_post&response_type=id_token&scope=openid' | ||||
|                    f'&redirect_uri={self.domain}/auth/callback' | ||||
|                    f'&client_id={self.client_id}' | ||||
|                    f'&nonce={self.nonce_gen()}' | ||||
|                    f'&state={self.state_gen()}') | ||||
|          | ||||
|     def check_bearer(self, token): | ||||
|         import jwt | ||||
|  | ||||
|         try: | ||||
|             signing_key = self.jwks_manager.get_signing_key_from_jwt(token).key | ||||
|             decoded = jwt.decode(token, signing_key, | ||||
|                                  algorithms=jwt.algorithms.get_default_algorithms(), | ||||
|                                  issuer=self.provider, | ||||
|                                  require=['aud', 'client_id', 'exp', 'iat', 'iss', 'rat', 'sub'], | ||||
|                                  audience=self.client_id) | ||||
|         except: | ||||
|             return False | ||||
|  | ||||
|         # double check if given token is really requested by us | ||||
|         if not self.nonce_check(decoded.get('nonce', None)): | ||||
|             return False | ||||
|  | ||||
|         return decoded.get('sub', False) | ||||
							
								
								
									
										50
									
								
								ayta/s3.py
									
									
									
									
									
								
							
							
						
						
									
										50
									
								
								ayta/s3.py
									
									
									
									
									
								
							| @@ -1,50 +0,0 @@ | ||||
| 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 | ||||
| @@ -70,7 +70,17 @@ | ||||
| 	  <div class="card black-text"> | ||||
|         <div class="card-content"> | ||||
|           <span class="card-title">Posters</span> | ||||
| 		  <p class="grey-text">User extension posters</p> | ||||
| 		  <p class="grey-text">Extension posters</p> | ||||
|         </div> | ||||
|       </div> | ||||
| 	</a> | ||||
|   </div> | ||||
|   <div class="col s6 l4 m-4"> | ||||
| 	<a href="{{ url_for('admin.users') }}"> | ||||
| 	  <div class="card black-text"> | ||||
|         <div class="card-content"> | ||||
|           <span class="card-title">Users</span> | ||||
| 		  <p class="grey-text">Authenticated users</p> | ||||
|         </div> | ||||
|       </div> | ||||
| 	</a> | ||||
|   | ||||
							
								
								
									
										82
									
								
								ayta/templates/admin/users.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								ayta/templates/admin/users.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| {% extends 'material_base.html' %} | ||||
| {% block title %}Users administration page{% endblock %} | ||||
| {% block description %}Users administration page of the AYTA system{% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="row"> | ||||
|   <div class="col s12 l11"> | ||||
|     <h4>Users administration page</h4> | ||||
|   </div> | ||||
| </div> | ||||
| <div class="divider"></div> | ||||
| <div class="row"> | ||||
|   <div class="col s6 l9"> | ||||
| 	<h5>All users</h5> | ||||
|   </div> | ||||
| </div> | ||||
| <div class="row"> | ||||
|   <div class="col s12 l4 m-4"> | ||||
| 	<div class="card"> | ||||
|       <div class="card-content"> | ||||
|         <span class="card-title">Authorize new user</span> | ||||
| 	    <form method="post"> | ||||
| 		  <div class="row"> | ||||
|             <div class="col s12 input-field"> | ||||
|               <input placeholder="sub" name="value" type="text" class="validate" required> | ||||
| 			  <span class="supporting-text">Unique identifier</span> | ||||
| 	        </div> | ||||
| 			<div class="col s12 input-field"> | ||||
|               <input placeholder="Alias" name="alias" type="text" class="validate"required> | ||||
| 			  <span class="supporting-text">Name of the user</span> | ||||
| 	        </div> | ||||
| 			<div class="col s12 input-field"> | ||||
|               <input placeholder="Description" name="description" type="text" class="validate"> | ||||
| 			  <span class="supporting-text">Additional information</span> | ||||
| 	        </div> | ||||
|           <button class="btn mt-4" type="submit" name="task" value="add-user">Create</button> | ||||
| 		  </div> | ||||
| 	    </form> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
| <div class="divider"></div> | ||||
| <div class="row"> | ||||
|   <div class="col s6 l9"> | ||||
| 	<h5>Registered users</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>sub</th> | ||||
| 		  <th>Alias</th> | ||||
| 		  <th>Description</th> | ||||
|         </tr> | ||||
|       </thead> | ||||
|       <tbody> | ||||
| 	    {% for user in users %} | ||||
|         <tr class="filterable"> | ||||
| 		  <td> | ||||
| 		    <form method="post"> | ||||
| 			  <input type="text" value="{{ user.get('sub') }}" name="value" hidden> | ||||
| 		      <button class="btn-small waves-effect waves-light" type="submit" name="task" value="delete-user" title="Delete user">🗑️</button> | ||||
| 			</form> | ||||
| 		  </td> | ||||
| 		  <td>{{ user.get('sub') }}</td> | ||||
| 		  <td>{{ user.get('alias') }}</td> | ||||
| 		  <td>{{ user.get('description') }}</td> | ||||
|         </tr> | ||||
| 		{% endfor %} | ||||
|       </tbody> | ||||
|     </table> | ||||
|   </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
| @@ -25,7 +25,7 @@ | ||||
| 	<div class="card medium black-text"> | ||||
| 	  <a href="{{ url_for('watch.base') }}?v={{ video.get('id') }}"> | ||||
| 	    <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> | ||||
| 	  </a> | ||||
|       <div class="card-content activator"> | ||||
|   | ||||
| @@ -19,7 +19,17 @@ | ||||
|   </div> | ||||
| </div> | ||||
| <div class="row"> | ||||
|   <div class="col s12 m-4 filterable"> | ||||
|   <div class="col s6 m-4"> | ||||
| 	<a href="{{ url_for('channel.recent') }}"> | ||||
| 	  <div class="card black-text"> | ||||
|         <div class="card-content center"> | ||||
|           <span class="card-title">Recent videos</span> | ||||
| 		  <p class="grey-text">The last videos to have been added to the archive</p> | ||||
|         </div> | ||||
|       </div> | ||||
| 	</a> | ||||
|   </div> | ||||
|   <div class="col s6 m-4"> | ||||
| 	<a href="{{ url_for('channel.orphaned') }}"> | ||||
| 	  <div class="card black-text"> | ||||
|         <div class="card-content center"> | ||||
| @@ -31,12 +41,12 @@ | ||||
|   </div> | ||||
|   {% for channel in channels %} | ||||
|   <div class="col s6 l4 m-4 filterable"> | ||||
| 	<a href="{{ url_for('channel.channel', channelId=channel) }}"> | ||||
| 	<a href="{{ url_for('channel.channel', channelId=channel.get('id')) }}"> | ||||
| 	  <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><b>Added:</b> {{ channels[channel].get('added_date')|pretty_time }} | <b>Active:</b> {{ channels[channel].get('active') }} | <b>Videos:</b> {{ channels[channel].get('video_count') }}</p> | ||||
|           <span class="card-title">{{ channel.get('original_name') }}</span> | ||||
| 		  <p class="grey-text">{{ channel.get('id') }}</p> | ||||
|           <p><b>Added:</b> {{ channel.get('added_date')|pretty_time }} | <b>Active:</b> {{ channel.get('active') }} | <b>Videos:</b> {{ channel.get('video_count') }}</p> | ||||
|         </div> | ||||
|       </div> | ||||
| 	</a> | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
| {% block content %} | ||||
| <div class="row"> | ||||
|   <div class="col s12"> | ||||
|     <h4>Channels lising page</h4> | ||||
|     <h4>Videos lising page</h4> | ||||
|   </div> | ||||
| </div> | ||||
| <div class="divider"></div> | ||||
| @@ -25,11 +25,12 @@ | ||||
| 	<div class="card medium black-text"> | ||||
| 	  <a href="{{ url_for('watch.base') }}?v={{ video.get('id') }}"> | ||||
| 	    <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> | ||||
| 	  </a> | ||||
|       <div class="card-content activator"> | ||||
|         <span class="card-title">{{ video.get('title') }}</span> | ||||
| 		<p><b>{{ video.get('uploader') }}</b></p> | ||||
| 		<p class="grey-text">{{ video.get('id') }} | {{ video.get('upload_date')|pretty_time }}</p> | ||||
|       </div> | ||||
|       <div class="card-reveal"> | ||||
|   | ||||
							
								
								
									
										44
									
								
								ayta/templates/channel/recent.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								ayta/templates/channel/recent.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| {% extends 'material_base.html' %} | ||||
| {% block title %}Recent videos{% endblock %} | ||||
| {% block description %}The last videos to have been added to the archive{% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="row"> | ||||
|   <div class="col s12"> | ||||
|     <h4>Videos lising page</h4> | ||||
|   </div> | ||||
| </div> | ||||
| <div class="divider"></div> | ||||
| <div class="row"> | ||||
|   <div class="col s6 l9"> | ||||
| 	<h5>Recent videos</h5> | ||||
| 	<p>The last 99 videos to have been added to the archive.</p> | ||||
|   </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 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.get('id') }}"> | ||||
| 	    <div class="card-image"> | ||||
| 		  <img loading="lazy" src="https://archive.ventilaar.net/videos/automatic/{{ video.get('channel_id') }}/{{ video.get('id') }}/{{ video.get('title_slug') }}.jpg"> | ||||
|         </div> | ||||
| 	  </a> | ||||
|       <div class="card-content activator"> | ||||
|         <span class="card-title">{{ video.get('title') }}</span> | ||||
| 		<p><b>{{ video.get('uploader') }}</b></p> | ||||
| 		<p class="grey-text">{{ video.get('id') }} | {{ video.get('upload_date')|pretty_time }}</p> | ||||
|       </div> | ||||
|       <div class="card-reveal"> | ||||
|         <span class="card-title truncate">{{ video.get('title') }}</span> | ||||
|         <p style="white-space: pre-wrap;">{{ video.get('description') }}</p> | ||||
|       </div> | ||||
| 	</div> | ||||
|   </div> | ||||
|   {% endfor %} | ||||
| </div> | ||||
| {% endblock %} | ||||
| @@ -4,7 +4,7 @@ | ||||
|  | ||||
| {% block content %} | ||||
| <div class="row"> | ||||
|   <div class="col s12 l3"> | ||||
|   <div class="col s12 l3 mr-4"> | ||||
|     <h4>pls login</h4> | ||||
| 	<form method="post"> | ||||
|       <div class="input-field"> | ||||
| @@ -12,10 +12,9 @@ | ||||
|       </div> | ||||
|       <button class="btn mt-4" type="submit" name="action" value="login">Login</button> | ||||
| 	</form> | ||||
|   </div> | ||||
| </div> | ||||
| 	<div class="divider"></div> | ||||
| <div class="row"> | ||||
| 	<a href="{{ url_for('auth.start_oidc') }}"><button class="btn mt-4 green">Login with OIDC</button></a> | ||||
|   </div> | ||||
|   <div class="col s12 l3"> | ||||
|     <p>This is a WEBP-free archive</p> | ||||
| 	<img class="responsive-img" src="{{ url_for('static', filename='img/fuck_webp.png') }}"> | ||||
|   | ||||
| @@ -20,11 +20,9 @@ | ||||
|           <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> | ||||
| 			{% if config.get('DEBUG') %}<li><span class="new badge mt-5" data-badge-caption="True">Debug mode is</span></li>{% endif %} | ||||
|           </ul> | ||||
|           <a href="{{ url_for('index.base') }}" class="brand-logo center">AYTA</a> | ||||
|           <ul id="nav-mobile" class="right"> | ||||
| 		    {% if 'username' in session %}<li><a href="{{ url_for('auth.logout') }}"><span class="new badge" data-badge-caption="{{ session.username }}">Logged in as</span></a></li>{% endif %} | ||||
|             <li><a href="{{ url_for('search.base') }}">Search</a></li> | ||||
|             <li><a href="{{ url_for('index.help') }}">Help</a></li> | ||||
|           </ul> | ||||
| @@ -36,6 +34,7 @@ | ||||
|       {% if messages %} | ||||
|       {% for message in messages %} | ||||
|       <script>M.toast({text: '{{ message }}', displayLength: 5000, outDuration: 999, inDuration: 666})</script> | ||||
|       <noscript>A message appeared without supporting javasript: {{ message }}</noscript> | ||||
|       {% endfor %} | ||||
|       {% endif %} | ||||
|       {% endwith %} | ||||
| @@ -47,14 +46,19 @@ | ||||
|     <footer class="page-footer deep-orange"> | ||||
|       <div class="container"> | ||||
|         <div class="row"> | ||||
|           <div class="s12 l6"> | ||||
|           <div class="s12 l6 mr-4"> | ||||
|              | ||||
|             <h5>Awesome YouTube Archive</h5> | ||||
|             <p>A custom content management system for archived YouTube videos!</p> | ||||
|             <p>A custom content management system for archived YouTube videos.</p> | ||||
|           </div> | ||||
|           <div class="s12 l6"> | ||||
| 			<span class="new badge" data-badge-caption="{{ null|current_time }}">Page generated on</span> | ||||
|             <h6>Still in development, slowly...</h6> | ||||
|             <h6>This is not a streaming website! Videos may buffer (a lot)!</h6> | ||||
|             <div class="section mb-4"> | ||||
|                 <span class="new badge" data-badge-caption="{{ null|current_time }}">Page generated on</span> | ||||
|                 {% if config.get('DEBUG') %}<span class="new badge" data-badge-caption="True">Debug mode is</span>{% endif %} | ||||
|                 {% if 'username' in session %}<a href="{{ url_for('auth.logout') }}"><span class="new badge" data-badge-caption="{{ session.username }}">Logged in as</span></a>{% endif %} | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|   | ||||
| @@ -7,50 +7,39 @@ | ||||
| <meta property="og:type" content="website" /> | ||||
| <meta property="og:url" content="{{ url_for('watch.base') }}?v={{ render.get('info').get('id') }}" /> | ||||
| <meta property="og:image" content="https://archive.ventilaar.net/videos/automatic/{{ render.get('info').get('channel_id') }}/{{ render.get('info').get('id') }}/{{ render.get('info').get('title') }}.jpg" /> | ||||
| <meta property="og:description" content="{{ render.get('info').get('description')|truncate(100) }}" /> | ||||
| <meta property="og:description" content="{{ render.get('info').get('description', '')|truncate(100) }}" /> | ||||
| {% 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')|pretty_time }}</p> | ||||
|   </div> | ||||
|   <div class="col s3"> | ||||
|     <p><b>Archive date:</b> {{ render.get('info').get('epoch')|epoch_time }}</p> | ||||
|   </div> | ||||
|   <div class="col s3"> | ||||
|     <p><b>Video length:</b> {{ render.get('info').get('duration')|pretty_duration }}</p> | ||||
|   </div> | ||||
| </div> | ||||
| <div class="row"> | ||||
|   <div class="col s12 center-align"> | ||||
|   <div class="col s12 mt-4 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"> | ||||
|     <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_slug') }}.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 m3"> | ||||
|   <div class="col s12 l9 mr-4"> | ||||
|     <h5>{{ render.get('info').get('title') }}</h5> | ||||
|   </div> | ||||
|   <div class="col s12 l3"> | ||||
|     <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> | ||||
|     <p><b>Upload date:</b> {{ render.get('info').get('upload_date')|pretty_time }}</p> | ||||
|     <p><b>Archive date:</b> {{ render.get('info').get('epoch')|epoch_time }}</p> | ||||
|     <p><b>Video length:</b> {{ render.get('info').get('duration')|pretty_duration }}</p> | ||||
|   </div> | ||||
|   <div class="col s4 l3 center-align"> | ||||
|     <p><a href="https://youtu.be/{{ render.get('info').get('id') }}" target="_blank" rel="noopener noreferrer">▶️ Watch on YouTube</a></p> | ||||
|   </div> | ||||
| 		<div class="col s12 m3"> | ||||
|   <div class="col s4 l3 center-align"> | ||||
|     <p><a href="https://archive.ventilaar.net/videos/automatic/{{ render.get('info').get('channel_id') }}/{{ render.get('info').get('id') }}/">🗄️ Source files</a></p> | ||||
|   </div> | ||||
| 		<div class="col s12 m3"> | ||||
| 	      <p>Sample text</p> | ||||
|   <div class="col s4 l3 center-align"> | ||||
|     <p></p> | ||||
|   </div> | ||||
| 		<div class="col s12 m3 input-field"> | ||||
|   <div class="col s12 l3 center-align input-field"> | ||||
|     <form method="post"> | ||||
|       <select id="report" name="reason"> | ||||
|         <option value="" disabled selected></option> | ||||
| @@ -59,18 +48,19 @@ | ||||
|         <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> | ||||
|       <button for="report" class="btn mt-4" type="submit" name="action">Submit report</button> | ||||
|     </form> | ||||
|   </div> | ||||
| </div> | ||||
| 	</div> | ||||
|     <div class="divider"></div> | ||||
|     <div class="section"> | ||||
| <div class="divider mt-4"></div> | ||||
| <div class="row"> | ||||
|   <div class="col s12 l9 mr-4"> | ||||
|     <div class="section center-align"> | ||||
|       <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"> | ||||
|     <div class="section center-align input-field"> | ||||
|       <h5>Full info JSON dump</h5> | ||||
|       <textarea readonly class="materialize-textarea grey lighten">{{ render.get('info') }}</textarea> | ||||
|     </div> | ||||
|   | ||||
| @@ -2,13 +2,11 @@ | ||||
|  | ||||
| flask | ||||
| flask-caching | ||||
| flask-login | ||||
| flask-oidc | ||||
| flask-limiter | ||||
| minio | ||||
| pymongo | ||||
| yt-dlp | ||||
| argon2-cffi | ||||
| gunicorn | ||||
| celery | ||||
| sqlalchemy | ||||
| pyjwt | ||||
		Reference in New Issue
	
	Block a user