1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
|
#!/usr/bin/env python3
# pylint: disable=no-member,method-hidden
import os
import sys
from aiohttp import web
import socketio
import logging
import json
import pathlib
from ytdl import DownloadQueueNotifier, DownloadQueue
log = logging.getLogger('main')
class Config:
_DEFAULTS = {
'DOWNLOAD_DIR': '.',
'AUDIO_DOWNLOAD_DIR': '%%DOWNLOAD_DIR',
'CUSTOM_DIRS': 'true',
'CREATE_CUSTOM_DIRS': 'true',
'STATE_DIR': '.',
'URL_PREFIX': '',
'OUTPUT_TEMPLATE': '%(title)s.%(ext)s',
'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)s %(section_title)s.%(ext)s',
'YTDL_OPTIONS': '{}',
'HOST': '0.0.0.0',
'PORT': '8081',
'BASE_DIR': ''
}
_BOOLEAN = ('CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS')
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'))
if not self.URL_PREFIX.endswith('/'):
self.URL_PREFIX += '/'
try:
self.YTDL_OPTIONS = json.loads(self.YTDL_OPTIONS)
assert isinstance(self.YTDL_OPTIONS, dict)
except (json.decoder.JSONDecodeError, AssertionError):
log.error('YTDL_OPTIONS is invalid')
sys.exit(1)
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()
sio = socketio.AsyncServer(cors_allowed_origins='*')
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
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))
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))
dqueue = DownloadQueue(config, Notifier())
app.on_startup.append(lambda app: dqueue.initialize())
@routes.post(config.URL_PREFIX + 'add')
async def add(request):
post = await request.json()
url = post.get('url')
quality = post.get('quality')
if not url or not quality:
raise web.HTTPBadRequest()
format = post.get('format')
folder = post.get('folder')
status = await dqueue.add(url, quality, format, folder)
return web.Response(text=serializer.encode(status))
@routes.post(config.URL_PREFIX + 'delete')
async def delete(request):
post = await request.json()
ids = post.get('ids')
where = post.get('where')
if not ids or where not in ['queue', 'done']:
raise web.HTTPBadRequest()
status = await (dqueue.cancel(ids) if where == 'queue' else dqueue.clear(ids))
return web.Response(text=serializer.encode(status))
@sio.event
async def connect(sid, environ):
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
}
@routes.get(config.URL_PREFIX)
def index(request):
return web.FileResponse(os.path.join(config.BASE_DIR, 'ui/dist/metube/index.html'))
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)
routes.static(config.URL_PREFIX + 'favicon/', os.path.join(config.BASE_DIR, 'favicon'))
routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR)
routes.static(config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR)
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
# 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)
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG)
web.run_app(app, host=config.HOST, port=config.PORT, reuse_port=True)
|