major rework of ythdd.py, new method for setting config in ythdd_globals.py

- slightly modified config, api keys now have the value "CHANGEME"
- requirements.txt has new dependency, flask apscheduler
- ythdd.py has been reworked, support for argument parsing has been added, code is now split into functions
- ythdd_api_v1.py features real uptime as well
- ythdd_db.py is no longer dependent on ythdd_globals.py
- ythdd_globals.py has a method for setting config and getting it from configfile variable
This commit is contained in:
2024-12-12 11:02:11 +01:00
parent c60f7db698
commit 1e4b05c33b
7 changed files with 161 additions and 60 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# dotfiles, catalogues
.venv
.git
__pycache__
instance
# config files
config.toml

View File

@@ -4,9 +4,8 @@ video_storage_directory_path = "/path/to/videos/" # Path to video vault.
is_proxied = false is_proxied = false
[api] [api]
# Leave empty to autogenerate api keys every launch (secure). api_key = "" # Leave empty API key for public access to non-sensitive backend
api_key = "" api_key_admin = "CHANGEME" # Empty *admin* API key will autogenerate a random one every launch.
api_key_admin = ""
[extractor] [extractor]
user-agent = "" # leave empty for default user-agent = "" # leave empty for default

View File

@@ -9,3 +9,6 @@ greenlet>=3.1.0
SQLAlchemy>=2.0.36.dev0 SQLAlchemy>=2.0.36.dev0
Flask-SQLAlchemy>=3.1.1 Flask-SQLAlchemy>=3.1.1
toml>=0.10.2 toml>=0.10.2
Flask-APScheduler>=1.13.1
requests>=2.32.3
yt_dlp

106
ythdd.py
View File

@@ -2,40 +2,73 @@
from flask import Flask, render_template from flask import Flask, render_template
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from markupsafe import escape from markupsafe import escape
#from argparse import ArgumentParser from argparse import ArgumentParser
from ythdd_globals import config, colors from ythdd_globals import colors
import requests, json, toml, time import requests, json, toml, time
import views, downloader, ythdd_api, ythdd_globals, ythdd_db import views, downloader, ythdd_api, ythdd_globals, ythdd_db
from flask_apscheduler import APScheduler
ythdd_globals.starttime = int(time.time())
ythdd_globals.apiRequests = 0
ythdd_globals.apiFailedRequests = 0
ythdd_globals.isProxied = config['general']['is_proxied']
ythdd_globals.outsideApiHits = 0
app = Flask(__name__) app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{config['general']['db_file_path']}"
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.add_url_rule('/', view_func=views.index)
app.add_url_rule('/index.html', view_func=views.index)
app.add_url_rule('/home', view_func=views.home)
app.add_url_rule('/api/', view_func=ythdd_api.api_greeting)
app.add_url_rule('/api/<path:received_request>', view_func=ythdd_api.api_global_catchall)
db = ythdd_db.initDB(app)
with app.app_context(): def setup():
# sanity check: make sure config is set
# required to make `flask --app ythdd run --debug` work
global config
try:
if not config['general']:
ythdd_globals.setConfig(ythdd_globals.configfile)
config = ythdd_globals.config
except:
ythdd_globals.setConfig(ythdd_globals.configfile)
config = ythdd_globals.config
# setting all the variables
ythdd_globals.starttime = int(time.time())
ythdd_globals.realUptime = 0
ythdd_globals.apiRequests = 0
ythdd_globals.apiFailedRequests = 0
ythdd_globals.isProxied = config['general']['is_proxied']
ythdd_globals.outsideApiHits = 0
app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{config['general']['db_file_path']}"
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.add_url_rule('/', view_func=views.index)
app.add_url_rule('/index.html', view_func=views.index)
app.add_url_rule('/home', view_func=views.home)
app.add_url_rule('/api/', view_func=ythdd_api.api_greeting)
app.add_url_rule('/api/<path:received_request>', view_func=ythdd_api.api_global_catchall)
db = ythdd_db.initDB(app, config)
with app.app_context():
db.create_all() db.create_all()
# job scheduler for repetetive tasks
scheduler = APScheduler()
scheduler.add_job(func=every5seconds, trigger='interval', id='job', seconds=5)
scheduler.start()
# gets called every 5 seconds
def every5seconds():
# update the "real" uptime counter
ythdd_globals.realUptime += 5
@app.route('/<string:val>', methods=['GET']) @app.route('/<string:val>', methods=['GET'])
def blank(val): def blank(val):
return f"{val}: not implemented in ythdd {ythdd_globals.version}" return f"{val}: not implemented in ythdd {ythdd_globals.version}"
#users = db.session.query(LocalUsers).all() #users = db.session.query(LocalUsers).all()
#return users #return users
def main(): def main(args):
print(f"{colors.BOLD + colors.HEADER}Welcome to ythdd ({ythdd_globals.version})!{colors.ENDC}") print(f"{colors.BOLD + colors.HEADER}Welcome to ythdd ({ythdd_globals.version})!{colors.ENDC}")
print(f"To run in development mode (to see changes updated live), use: {colors.OKCYAN}flask --app ythdd run --debug{colors.ENDC}.") print(f"To run in development mode (to see changes updated live), use: {colors.OKCYAN}flask --app ythdd run --debug{colors.ENDC}.")
print("To run locally, use IP 127.0.0.1. To run on all interfaces, use 0.0.0.0.\n") print("To run locally, use IP 127.0.0.1. To run on all interfaces, use 0.0.0.0.\n")
try:
host = args.ip
port = args.port
if not host or not port:
raise Exception
except:
print("Enter hostname:port to run Flask app on [127.0.0.1:5000]:") print("Enter hostname:port to run Flask app on [127.0.0.1:5000]:")
try: try:
host_port = input('> ').split(':') host_port = input('> ').split(':')
@@ -45,10 +78,41 @@ def main():
except KeyboardInterrupt: except KeyboardInterrupt:
print(" ...exiting gracefully."), quit() # handle Ctrl+C print(" ...exiting gracefully."), quit() # handle Ctrl+C
app.run(host=host_port[0], port=int(host_port[1])) host = host_port[0]
port = host_port[1]
global config
try:
# if specified, use custom config file
ythdd_globals.configfile = args.config
ythdd_globals.setConfig(ythdd_globals.configfile)
except:
# if not, use dummy file
ythdd_globals.configfile = ""
# but try to set the API secret if provided by the user
if args.secret:
ythdd_globals.randomly_generated_passcode = args.secret
ythdd_globals.setConfig(ythdd_globals.configfile)
config = ythdd_globals.config
setup()
app.run(host=host, port=int(port))
if __name__ == "__main__": if __name__ == "__main__":
#app.run(host="127.0.0.1", port=5000) #app.run(host="127.0.0.1", port=5000)
#app.run(host="0.0.0.0", port=5000) #app.run(host="0.0.0.0", port=5000)
# TODO: add argument parser to load config files, etc. parser = ArgumentParser(description='A basic yt_dlp-based script for video download.')
main()
parser.add_argument("-i", "--ip", dest="ip", help="ip address/interface to bind to")
parser.add_argument("-p", "--port", dest="port", help="port on which the flask web backend should be ran")
parser.add_argument("-c", "--config", dest="config", help="path to TOML config file")
parser.add_argument("-s", "--secret", dest="secret", help="admin's secret passcode for sensitive API access")
args = parser.parse_args()
main(args)
else:
setup()

View File

@@ -20,8 +20,10 @@ def stub_hello():
def stats(): def stats():
data_to_send = { data_to_send = {
"starttime": ythdd_globals.starttime, # TODO: include yt-dlp version
"start_time": ythdd_globals.starttime,
"uptime": ythdd_globals.getUptime(), "uptime": ythdd_globals.getUptime(),
"real_uptime": ythdd_globals.realUptime,
"total_api_requests": ythdd_globals.apiRequests, "total_api_requests": ythdd_globals.apiRequests,
"failed_api_requests": ythdd_globals.apiFailedRequests, "failed_api_requests": ythdd_globals.apiFailedRequests,
"outside_api_requests": ythdd_globals.outsideApiHits, "outside_api_requests": ythdd_globals.outsideApiHits,
@@ -29,6 +31,12 @@ def stats():
} }
return 200, "OK", data_to_send return 200, "OK", data_to_send
def videoIdSanityCheck(videoId: str):
if len(videId) != 11:
incrementBadRequests()
return 400, f'error: bad request. wrong videoId: {videoId} is {len(videoId)} characters long, but should be 11.', []
# elif... check
def hot(data): def hot(data):
#print(data) #print(data)
# if we are given not enough data to work with, return bad request. # if we are given not enough data to work with, return bad request.
@@ -119,7 +127,7 @@ def hot(data):
case _: case _:
incrementBadRequests() incrementBadRequests()
return notImplemented(data) return notImplemented([data[1]]) # workaround before notImplemented is reworked
def lookup(data): def lookup(data):
match data[0]: match data[0]:

View File

@@ -1,14 +1,14 @@
#!/usr/bin/python3 #!/usr/bin/python3
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
import toml import toml
import ythdd_globals # import ythdd_globals
database_file = ythdd_globals.config["general"]["db_file_path"] # database_file = ythdd_globals.config["general"]["db_file_path"]
global db global db
#db = SQLAlchemy() #db = SQLAlchemy()
def initDB(app): def initDB(app, config):
db = SQLAlchemy(app) db = SQLAlchemy(app)
class LocalUsers(db.Model): class LocalUsers(db.Model):

View File

@@ -1,7 +1,7 @@
#!/usr/bin/python3 #!/usr/bin/python3
import time, toml, os import time, toml, os
global starttime, apiRequests, apiFailedRequests, outsideApiHits, config, version, apiVersion, colors global starttime, apiRequests, apiFailedRequests, outsideApiHits, config, version, apiVersion, colors, realUptime
class colors: class colors:
HEADER = '\033[95m' HEADER = '\033[95m'
@@ -18,22 +18,41 @@ class colors:
def notImplemented(name): def notImplemented(name):
return 501, f"not recognised/implemented: {name}", [] return 501, f"not recognised/implemented: {name}", []
configfile = "config.toml" # TODO: implement a way to specify alternative config file path configfile = "config.toml"
version = "0.0.1" version = "0.0.1"
apiVersion = "1" apiVersion = "1"
randomly_generated_passcode = 0
# TODO: turn this into function, to make setting configfile with argparser (and effectively using a custom config file) possible def getConfig(configfile):
if not os.path.exists(configfile):
# use dummy default config, TODO: update this in the near future # this function is responsible for an unwanted warning when using --help without config.toml
config = {'general': {'db_file_path': 'ythdd_db.sqlite', 'video_storage_directory_path': 'videos/', 'is_proxied': False}, 'api': {'api_key': 'CHANGEME', 'api_key_admin': str(int(time.time()*1337 % 899_999 + 100_000))}, 'extractor': {'user-agent': '', 'cookies_path': ''}, 'admin': {'admins': ['admin']}, 'yt_dlp': {}, 'postprocessing': {'presets': [{'name': 'recommended: [N][<=720p] best V+A', 'format': 'bv[height<=720]+ba', 'reencode': ''}, {'name': '[N][1080p] best V+A', 'format': 'bv[height=1080]+ba', 'reencode': ''}, {'name': '[R][1080p] webm', 'format': 'bv[height=1080]+ba', 'reencode': 'webm'}, {'name': '[N][720p] best V+A', 'format': 'bv[height=720]+ba', 'reencode': ''}, {'name': '[R][720p] webm', 'format': 'bv[height=720]+ba', 'reencode': 'webm'}, {'name': '[N][480p] best V+A', 'format': 'bv[height=480]+ba', 'reencode': ''}, {'name': '[480p] VP9 webm/reencode', 'format': 'bv*[height=480][ext=webm]+ba/bv[height=480]+ba', 'reencode': 'webm'}, {'name': '[N][1080p] best video only', 'format': 'bv[height=1080]', 'reencode': ''}, {'name': '[N][opus] best audio only', 'format': 'ba', 'reencode': 'opus'}]}} # for now it's not worth it to account for that edge case.
global randomly_generated_passcode
if not os.path.exists(configfile):
dummy_config = {'general': {'db_file_path': 'ythdd_db.sqlite', 'video_storage_directory_path': 'videos/', 'is_proxied': False}, 'api': {'api_key': 'CHANGEME'}, 'extractor': {'user-agent': '', 'cookies_path': ''}, 'admin': {'admins': ['admin']}, 'yt_dlp': {}, 'postprocessing': {'presets': [{'name': 'recommended: [N][<=720p] best V+A', 'format': 'bv[height<=720]+ba', 'reencode': ''}, {'name': '[N][1080p] best V+A', 'format': 'bv[height=1080]+ba', 'reencode': ''}, {'name': '[R][1080p] webm', 'format': 'bv[height=1080]+ba', 'reencode': 'webm'}, {'name': '[N][720p] best V+A', 'format': 'bv[height=720]+ba', 'reencode': ''}, {'name': '[R][720p] webm', 'format': 'bv[height=720]+ba', 'reencode': 'webm'}, {'name': '[N][480p] best V+A', 'format': 'bv[height=480]+ba', 'reencode': ''}, {'name': '[480p] VP9 webm/reencode', 'format': 'bv*[height=480][ext=webm]+ba/bv[height=480]+ba', 'reencode': 'webm'}, {'name': '[N][1080p] best video only', 'format': 'bv[height=1080]', 'reencode': ''}, {'name': '[N][opus] best audio only', 'format': 'ba', 'reencode': 'opus'}]}}
# if a passcode has not been provided by the user (config file doesn't exist, and user didn't specify it using an argument)
print(f"{colors.WARNING}WARNING{colors.ENDC}: Using default, baked in config data. {colors.ENDL}" print(f"{colors.WARNING}WARNING{colors.ENDC}: Using default, baked in config data. {colors.ENDL}"
f"Consider copying and editing the provided example file ({colors.OKCYAN}config.default.toml{colors.ENDC}).") f"Consider copying and editing the provided example file ({colors.OKCYAN}config.default.toml{colors.ENDC}).")
print(f"{colors.WARNING}WARNING{colors.ENDC}: Default config populated with one-time, insecure pseudorandom admin API key: {colors.OKCYAN}{config['api']['api_key_admin']}{colors.ENDC}." if randomly_generated_passcode == 0:
f" {colors.ENDL}You need to provide a config file for persistence!{colors.ENDL}") # generate a pseudorandom one and use it in the temporary config
#with open(configfile, "w") as file: randomly_generated_passcode = str(int(time.time() * 1337 % 899_999 + 100_000))
# file.write(toml.dumps(config))
else: print(f"{colors.WARNING}WARNING{colors.ENDC}: Default config populated with one-time, insecure pseudorandom admin API key: {colors.OKCYAN}{randomly_generated_passcode}{colors.ENDC}."
config = toml.load(configfile) f" {colors.ENDL}The admin API key is not the Flask debugger PIN. You need to provide a config file for persistence!{colors.ENDL}")
dummy_config['api']['api_key_admin'] = randomly_generated_passcode
return dummy_config
else:
return toml.load(configfile)
def setConfig(configfile):
global config
config = getConfig(configfile)
#setConfig(configfile)
config = {}
def getUptime(): def getUptime():
return int(time.time()) - starttime return int(time.time()) - starttime