metube/app/main.py

239 lines
8.3 KiB
Python
Raw Normal View History

2019-11-29 17:31:34 +00:00
#!/usr/bin/env python3
2019-12-03 21:18:14 +00:00
# pylint: disable=no-member,method-hidden
2019-11-29 17:31:34 +00:00
import os
import sys
2019-11-29 17:31:34 +00:00
from aiohttp import web
import socketio
import logging
import json
import pathlib
2019-11-29 17:31:34 +00:00
from ytdl import DownloadQueueNotifier, DownloadQueue
log = logging.getLogger('main')
2023-08-14 01:53:16 +00:00
2019-11-29 17:31:34 +00:00
class Config:
_DEFAULTS = {
'DOWNLOAD_DIR': '.',
'AUDIO_DOWNLOAD_DIR': '%%DOWNLOAD_DIR',
'TEMP_DIR': '%%DOWNLOAD_DIR',
'DOWNLOAD_DIRS_INDEXABLE': 'false',
'CUSTOM_DIRS': 'true',
2022-09-19 19:00:26 +00:00
'CREATE_CUSTOM_DIRS': 'true',
'DELETE_FILE_ON_TRASHCAN': 'false',
2022-01-17 18:47:32 +00:00
'STATE_DIR': '.',
2019-12-03 21:18:14 +00:00
'URL_PREFIX': '',
'OUTPUT_TEMPLATE': '%(title)s.%(ext)s',
'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)s %(section_title)s.%(ext)s',
'YTDL_OPTIONS': '{}',
2023-08-19 21:01:45 +00:00
'YTDL_OPTIONS_FILE': '',
2022-08-25 15:01:06 +00:00
'HOST': '0.0.0.0',
'PORT': '8081',
'BASE_DIR': '',
'DEFAULT_THEME': 'auto'
2019-11-29 17:31:34 +00:00
}
_BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN')
2019-11-29 17:31:34 +00:00
def __init__(self):
for k, v in self._DEFAULTS.items():
setattr(self, k, os.environ[k] if k in os.environ else v)
for k, v in self.__dict__.items():
if v.startswith('%%'):
setattr(self, k, getattr(self, v[2:]))
if k in self._BOOLEAN:
if v not in ('true', 'false', 'True', 'False', 'on', 'off', '1', '0'):
log.error(f'Environment variable "{k}" is set to a non-boolean value "{v}"')
sys.exit(1)
setattr(self, k, v in ('true', 'True', 'on', '1'))
2019-12-06 12:16:19 +00:00
if not self.URL_PREFIX.endswith('/'):
self.URL_PREFIX += '/'
try:
2023-08-19 21:01:45 +00:00
self.YTDL_OPTIONS = json.loads(self.YTDL_OPTIONS)
assert isinstance(self.YTDL_OPTIONS, dict)
2023-08-19 21:01:45 +00:00
except (json.decoder.JSONDecodeError, AssertionError):
log.error('YTDL_OPTIONS is invalid')
sys.exit(1)
2019-11-29 17:31:34 +00:00
2023-08-19 21:01:45 +00:00
if self.YTDL_OPTIONS_FILE:
log.info(f'Loading yt-dlp custom options from "{self.YTDL_OPTIONS_FILE}"')
if not os.path.exists(self.YTDL_OPTIONS_FILE):
log.error(f'File "{self.YTDL_OPTIONS_FILE}" not found')
sys.exit(1)
try:
with open(self.YTDL_OPTIONS_FILE) as json_data:
opts = json.load(json_data)
assert isinstance(opts, dict)
except (json.decoder.JSONDecodeError, AssertionError):
log.error('YTDL_OPTIONS_FILE contents is invalid')
sys.exit(1)
self.YTDL_OPTIONS.update(opts)
2019-11-29 17:31:34 +00:00
config = Config()
class ObjectSerializer(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, object):
return obj.__dict__
else:
return json.JSONEncoder.default(self, obj)
serializer = ObjectSerializer()
app = web.Application()
2021-09-18 20:23:02 +00:00
sio = socketio.AsyncServer(cors_allowed_origins='*')
2019-12-06 12:16:19 +00:00
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
2019-11-29 17:31:34 +00:00
routes = web.RouteTableDef()
class Notifier(DownloadQueueNotifier):
async def added(self, dl):
await sio.emit('added', serializer.encode(dl))
async def updated(self, dl):
await sio.emit('updated', serializer.encode(dl))
2019-12-03 20:32:07 +00:00
async def completed(self, dl):
await sio.emit('completed', serializer.encode(dl))
async def canceled(self, id):
await sio.emit('canceled', serializer.encode(id))
async def cleared(self, id):
await sio.emit('cleared', serializer.encode(id))
2019-11-29 17:31:34 +00:00
dqueue = DownloadQueue(config, Notifier())
2022-01-25 21:56:17 +00:00
app.on_startup.append(lambda app: dqueue.initialize())
2019-11-29 17:31:34 +00:00
2019-12-03 21:18:14 +00:00
@routes.post(config.URL_PREFIX + 'add')
2019-11-29 17:31:34 +00:00
async def add(request):
post = await request.json()
url = post.get('url')
2019-12-13 20:43:58 +00:00
quality = post.get('quality')
if not url or not quality:
2019-11-29 17:31:34 +00:00
raise web.HTTPBadRequest()
2021-09-13 17:25:32 +00:00
format = post.get('format')
folder = post.get('folder')
2023-04-09 03:27:41 +00:00
custom_name_prefix = post.get('custom_name_prefix')
2023-12-09 04:35:31 +00:00
auto_start = post.get('auto_start')
2023-04-09 03:27:41 +00:00
if custom_name_prefix is None:
custom_name_prefix = ''
2023-12-09 04:35:31 +00:00
status = await dqueue.add(url, quality, format, folder, custom_name_prefix, auto_start)
2019-11-29 17:31:34 +00:00
return web.Response(text=serializer.encode(status))
2019-12-03 21:18:14 +00:00
@routes.post(config.URL_PREFIX + 'delete')
2019-11-29 17:31:34 +00:00
async def delete(request):
post = await request.json()
ids = post.get('ids')
2019-12-03 20:32:07 +00:00
where = post.get('where')
if not ids or where not in ['queue', 'done']:
2019-11-29 17:31:34 +00:00
raise web.HTTPBadRequest()
2019-12-03 20:32:07 +00:00
status = await (dqueue.cancel(ids) if where == 'queue' else dqueue.clear(ids))
2019-11-29 17:31:34 +00:00
return web.Response(text=serializer.encode(status))
2023-12-09 04:35:31 +00:00
@routes.post(config.URL_PREFIX + 'start')
async def start(request):
post = await request.json()
ids = post.get('ids')
status = await dqueue.start_pending(ids)
return web.Response(text=serializer.encode(status))
@routes.get(config.URL_PREFIX + 'history')
2023-11-03 12:23:02 +00:00
async def history(request):
history = { 'done': [], 'queue': []}
for _ ,v in dqueue.queue.saved_items():
history['queue'].append(v)
for _ ,v in dqueue.done.saved_items():
history['done'].append(v)
return web.Response(text=serializer.encode(history))
2019-11-29 17:31:34 +00:00
@sio.event
async def connect(sid, environ):
2019-12-03 20:32:07 +00:00
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
await sio.emit('configuration', serializer.encode(config), to=sid)
if config.CUSTOM_DIRS:
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
def get_custom_dirs():
def recursive_dirs(base):
path = pathlib.Path(base)
# Converts PosixPath object to string, and remove base/ prefix
def convert(p):
s = str(p)
if s.startswith(base):
s = s[len(base):]
if s.startswith('/'):
s = s[1:]
return s
# Recursively lists all subdirectories of DOWNLOAD_DIR
dirs = list(filter(None, map(convert, path.glob('**'))))
return dirs
download_dir = recursive_dirs(config.DOWNLOAD_DIR)
audio_download_dir = download_dir
if config.DOWNLOAD_DIR != config.AUDIO_DOWNLOAD_DIR:
audio_download_dir = recursive_dirs(config.AUDIO_DOWNLOAD_DIR)
return {
"download_dir": download_dir,
"audio_download_dir": audio_download_dir
}
2019-11-29 17:31:34 +00:00
2019-12-03 21:18:14 +00:00
@routes.get(config.URL_PREFIX)
2019-11-29 17:31:34 +00:00
def index(request):
response = web.FileResponse(os.path.join(config.BASE_DIR, 'ui/dist/metube/index.html'))
if 'metube_theme' not in request.cookies:
response.set_cookie('metube_theme', config.DEFAULT_THEME)
return response
2019-11-29 17:31:34 +00:00
2019-12-03 21:18:14 +00:00
if config.URL_PREFIX != '/':
@routes.get('/')
def index_redirect_root(request):
return web.HTTPFound(config.URL_PREFIX)
@routes.get(config.URL_PREFIX[:-1])
def index_redirect_dir(request):
return web.HTTPFound(config.URL_PREFIX)
2022-08-25 15:01:06 +00:00
routes.static(config.URL_PREFIX + 'favicon/', os.path.join(config.BASE_DIR, 'favicon'))
routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
routes.static(config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
2022-08-25 15:01:06 +00:00
routes.static(config.URL_PREFIX, os.path.join(config.BASE_DIR, 'ui/dist/metube'))
try:
app.add_routes(routes)
except ValueError as e:
if 'ui/dist/metube' in str(e):
raise RuntimeError('Could not find the frontend UI static assets. Please run `node_modules/.bin/ng build` inside the ui folder') from e
raise e
2019-11-29 17:31:34 +00:00
2021-01-26 13:28:03 +00:00
# https://github.com/aio-libs/aiohttp/pull/4615 waiting for release
# @routes.options(config.URL_PREFIX + 'add')
async def add_cors(request):
return web.Response(text=serializer.encode({"status": "ok"}))
app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors)
async def on_prepare(request, response):
if 'Origin' in request.headers:
response.headers['Access-Control-Allow-Origin'] = request.headers['Origin']
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
app.on_response_prepare.append(on_prepare)
2019-11-29 17:31:34 +00:00
if __name__ == '__main__':
2023-08-19 21:01:45 +00:00
logging.basicConfig(level=logging.DEBUG)
log.info(f"Listening on {config.HOST}:{config.PORT}")
2023-12-09 04:43:10 +00:00
web.run_app(app, host=config.HOST, port=int(config.PORT), reuse_port=True)