]> git.ekhem.eu.org Git - ydlpd.git/commitdiff
Implement the server.
authorJakub Czajka <jakub@ekhem.eu.org>
Sun, 14 May 2023 10:31:41 +0000 (12:31 +0200)
committerJakub Czajka <jczajka@google.com>
Sun, 24 Dec 2023 18:50:35 +0000 (19:50 +0100)
README
requirements.txt [new file with mode: 0644]
server.py [new file with mode: 0644]

diff --git a/README b/README
index 9dcf542a73d185f36ac77acc0aaab8d494483e11..30f3cb5797f606a60f0cfa3db53fb70674c6c37f 100644 (file)
--- a/README
+++ b/README
@@ -4,6 +4,11 @@ yt-dlp-server
 HTTP interface for `yt-dlp`. Exposes a GET / endpoint with a HTML form for
 obtaining download parameters.
 
+Dependencies
+------------
+
+* ffmpeg
+
 Install
 -------
 
diff --git a/requirements.txt b/requirements.txt
new file mode 100644 (file)
index 0000000..d0d869d
--- /dev/null
@@ -0,0 +1 @@
+yt-dlp==2023.3.4
diff --git a/server.py b/server.py
new file mode 100644 (file)
index 0000000..bfc6b76
--- /dev/null
+++ b/server.py
@@ -0,0 +1,169 @@
+# Copyright (c) 2023 Jakub Czajka <jakub@ekhem.eu.org>
+# License: GPL-3.0 or later.
+
+import argparse
+import os
+import re
+import tempfile
+import urllib.parse
+import uuid
+import yt_dlp
+
+from wsgiref.simple_server import make_server
+
+FORMAT_OPTS = {
+    'video': {
+        'mime': 'video/mp4',
+        'extension': 'mp4',
+        'ydl_opts': {
+            'format': 'mp4',
+        }
+    },
+    'audio': {
+        'mime': 'audio/ogg',
+        'extension': 'ogg',
+        'ydl_opts': {
+            'format': '251',
+            'postprocessors': [{
+                'key': 'FFmpegExtractAudio',
+            }],
+            'postprocessor_args': [
+                '-ar', '16000'
+            ],
+            'prefer_ffmpeg': True
+        }
+    }
+}
+
+VIDEO_PARAMETERS_FORM = '''
+<!DOCTYPE html>
+<html>
+<head>
+  <title>YT Download</title>
+  <style>
+    body {{
+      border: ridge 5px;
+      max-width: max-content;
+    }}
+    .container {{
+      background-color: PowderBlue;
+      padding: 7px;
+    }}
+    form {{
+      display: grid;
+      grid-gap: 5px;
+      grid-template-columns: 1fr 4fr;
+    }}
+    #format {{
+      justify-self: end;
+      width: 50%;
+    }}
+  </style>
+</head>
+<body>
+  <div class="container">
+    <form>
+      <label for="url">URL:</label>
+      <input type="text" id="url" name="url">
+
+      <label for="format">Format:</label>
+      <input list="formats" id="format" name="format">
+
+      <datalist id="formats">
+       {0}
+      </datalist>
+
+      <input type="submit">
+    </form>
+  </div>
+</body>
+</html>
+'''
+
+YOUTUBE_URL_RE = '(https:\/\/)?(www\.)?youtu(\.be|be\.com)\/watch\?v=([\w\-_]+)'
+
+def build_form_for_video_parameters():
+   formats_as_html = ['<option value="{}">'.format(f) for f in FORMAT_OPTS]
+   return VIDEO_PARAMETERS_FORM.format(''.join(formats_as_html))
+
+def parse(query_string):
+    parameters = urllib.parse.parse_qs(query_string)
+    if not 'url' in parameters or not 'format' in parameters:
+        return None, None
+    return parameters['url'][0], parameters['format'][0]
+
+def set_temporary_output_file(ydl_opts, extension):
+    without_ext = tempfile.gettempdir() + '/' + str(uuid.uuid4())
+    output_file = without_ext + '.' + extension
+    if 'postprocessor_args' in ydl_opts:
+        ydl_opts['outtmpl'] = { 'default': without_ext }
+        ydl_opts['postprocessor_args'].append(output_file)
+    else:
+        ydl_opts['outtmpl'] = { 'default': output_file }
+    return output_file
+
+def wrap_file_for_serving(environ, file_path):
+    UPLOAD_BLOCK_SIZE = 1024
+    f = open(file_path, 'rb')
+    if 'wsgi.file_wrapper' in environ:
+        return environ['wsgi.file_wrapper'](f, UPLOAD_BLOCK_SIZE)
+    return iter(lambda: f.read(UPLOAD_BLOCK_SIZE), '')
+
+def handle_request(environ, start_response):
+    if not environ['QUERY_STRING']:
+        start_response('200 OK', [('Content-type', 'text/html')])
+        return [str.encode(build_form_for_video_parameters())]
+
+    video_url, output_format = parse(environ['QUERY_STRING'])
+    if not video_url:
+        print('Error: One or more empty parameters.')
+        start_response('200 OK', [('Content-type', 'text/plain')])
+        return [b'Error: One or more empty parameters.']
+
+    if not re.fullmatch(YOUTUBE_URL_RE, video_url):
+        print(f'{video_url} is not a valid YouTube URL.')
+        start_response('200 OK', [('Content-type', 'text/plain')])
+        return [str.encode(f'\'{video_url}\' is not a valid YouTube URL.')]
+
+    if not output_format in FORMAT_OPTS:
+        print(f'{output_format} is not a valid output format.')
+        start_response('200 OK', [('Content-type', 'text/plain')])
+        return [str.encode(f'\'{output_format}\' is not a valid output format.')]
+
+    ydl_opts = FORMAT_OPTS[output_format]['ydl_opts']
+    tmp_file = set_temporary_output_file(ydl_opts,
+        FORMAT_OPTS[output_format]['extension'])
+
+    print(f'Downloading {video_url}...')
+    with yt_dlp.YoutubeDL(ydl_opts) as ydl:
+        error_code = ydl.download([video_url])
+        if error_code:
+            print(f'Download failed with code {error_code}')
+            start_response('200 OK', [('Content-type', 'text/plain')])
+            return [str.encode(f'Download failed with code {error_code}')]
+    print('Done')
+
+    print(f'Sending {video_url}...')
+    start_response('200 OK', [
+        ('Content-type', FORMAT_OPTS[output_format]['mime']),
+        ('Content-Length', str(os.path.getsize(tmp_file))),
+        ('Content-Disposition', f'attachment; filename="{tmp_file}"')])
+    return wrap_file_for_serving(environ, tmp_file)
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(prog='yt-dlp-server', description='HTTP '
+        'server for downloading from YouTube. Exposes a GET / endpoint with a '
+        'HTML form for obtaining download parameters.')
+    parser.add_argument('-a', dest='host', default='localhost',
+        help='default=localhost')
+    parser.add_argument('-p', dest='port', type=int, required=True)
+
+    args = parser.parse_args()
+
+    with make_server(args.host, args.port, handle_request) as httpd:
+        print('Serving on {0}:{1}...'.format(args.host, args.port))
+        try:
+            httpd.serve_forever()
+        except KeyboardInterrupt:
+            print('Shutting down.')
+            httpd.server_close()