-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
141 lines (118 loc) · 5.28 KB
/
Copy pathserver.py
File metadata and controls
141 lines (118 loc) · 5.28 KB
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
#!/usr/bin/env python3
"""
Server for Crate Digger.
Serves the static files and the single-page app on the root route, plus one
extra endpoint the app's Download button calls:
GET /download?url=<youtube watch url>&title=<artist - title>
which shells out to yt-dlp to grab the audio as an MP3 and streams it back to
the browser as a file download.
Runs locally (double-click "Start Sample Digger.command") and on Codesphere.
The port comes from $PORT (Codesphere sets 3000); it defaults to 8765 locally.
Bound to 127.0.0.1 — Codesphere routes external traffic to localhost, and the
download endpoint executes a subprocess based on request input.
"""
import http.server
import json
import re
import shutil
import subprocess
import sys
import tempfile
import os
import urllib.parse
PORT = int(os.environ.get('PORT', '8765'))
# Locally bind loopback (the /download endpoint runs a subprocess, so don't expose
# it to the LAN); on Codesphere set HOST=0.0.0.0 so the workspace router can reach it.
HOST = os.environ.get('HOST', '127.0.0.1')
APP_FILE = 'sample-digger.html' # served on the main route "/"
YOUTUBE_RE = re.compile(r'^https://(www\.)?(youtube\.com/watch\?v=|youtu\.be/)[\w-]{11}([&?].*)?$')
# yt-dlp and ffmpeg are installed via Nix on Codesphere (see ci.yml), which lands
# them in ~/.nix-profile/bin. Prefer that, then anything on PATH, then the pip
# module (local dev). The subprocess PATH is augmented with the Nix bin dir so
# yt-dlp can find ffmpeg for the mp3 conversion.
NIX_BIN = os.path.expanduser('~/.nix-profile/bin')
def ytdlp_command():
nix = os.path.join(NIX_BIN, 'yt-dlp')
if os.path.exists(nix):
return [nix]
found = shutil.which('yt-dlp')
if found:
return [found]
return [sys.executable, '-m', 'yt_dlp']
def subprocess_env():
env = dict(os.environ)
if os.path.isdir(NIX_BIN):
env['PATH'] = NIX_BIN + os.pathsep + env.get('PATH', '')
return env
class Handler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
if parsed.path == '/health':
self.send_json(200, {'status': 'ok'})
elif parsed.path == '/download':
self.handle_download(parsed)
else:
if parsed.path == '/':
self.path = '/' + APP_FILE # serve the app on the main route
super().do_GET()
def handle_download(self, parsed):
qs = urllib.parse.parse_qs(parsed.query)
url = (qs.get('url') or [''])[0]
title = (qs.get('title') or ['track'])[0]
if not YOUTUBE_RE.match(url):
self.send_json(400, {'error': 'Not a valid YouTube URL.'})
return
safe_title = re.sub(r'[^\w\s.,()\'&-]', '', title).strip() or 'track'
with tempfile.TemporaryDirectory() as tmp:
outtmpl = os.path.join(tmp, '%(title)s.%(ext)s')
try:
subprocess.run(
ytdlp_command() + ['-x', '--audio-format', 'mp3',
'--audio-quality', '0', '--no-playlist', '-o', outtmpl, '--', url],
check=True, capture_output=True, text=True, timeout=180,
env=subprocess_env()
)
except FileNotFoundError:
self.send_json(500, {'error': 'yt-dlp is not installed on the server.'})
return
except subprocess.TimeoutExpired:
self.send_json(504, {'error': 'Download timed out.'})
return
except subprocess.CalledProcessError as e:
err = (e.stderr or '').strip()
if 'No module named' in err:
self.send_json(500, {'error': 'yt-dlp is not installed on the server.'})
return
msg = err.splitlines()[-1] if err else 'yt-dlp failed.'
self.send_json(502, {'error': msg[:200]})
return
files = [f for f in os.listdir(tmp) if f.endswith('.mp3')]
if not files:
self.send_json(502, {'error': 'No audio file produced (is ffmpeg installed?).'})
return
path = os.path.join(tmp, files[0])
size = os.path.getsize(path)
fname = urllib.parse.quote(safe_title + '.mp3')
self.send_response(200)
self.send_header('Content-Type', 'audio/mpeg')
self.send_header('Content-Length', str(size))
self.send_header('Content-Disposition', f"attachment; filename*=UTF-8''{fname}")
self.end_headers()
with open(path, 'rb') as f:
self.wfile.write(f.read())
def send_json(self, status, obj):
body = json.dumps(obj).encode('utf-8')
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
if '/download' in (self.path or ''):
super().log_message(fmt, *args)
# keep static-file request logs quiet
if __name__ == '__main__':
os.chdir(os.path.dirname(os.path.abspath(__file__)))
httpd = http.server.ThreadingHTTPServer((HOST, PORT), Handler)
print(f'Serving Crate Digger on http://{HOST}:{PORT} (Ctrl+C to stop)')
httpd.serve_forever()