Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
4.1 KiB
Python
107 lines
4.1 KiB
Python
"""MP3 delivery: local files with HTTP Range, and a streaming proxy to the home-server archive."""
|
|
import http.client
|
|
import os
|
|
import re
|
|
from urllib.parse import quote, unquote, urlparse
|
|
|
|
from radio.config import ARCHIVE_URL, REC_DIR
|
|
from radio.recordings import DAY_RE, FILE_RE, arch_index
|
|
|
|
|
|
def serve(h, rel, dl):
|
|
"""DAY/FILE (or DAY/noise/FILE) as audio/mpeg with Range support; ?dl=1 -> attachment."""
|
|
parts = unquote(rel).split("/")
|
|
if len(parts) == 3 and parts[1] == "noise":
|
|
parts = [parts[0], parts[2]]
|
|
if len(parts) != 2 or not DAY_RE.match(parts[0]) or not FILE_RE.match(parts[1]):
|
|
return h.send(404, "not found", "text/plain")
|
|
fh = None
|
|
# the classifier may move a file into noise/ while it is being played: try both places
|
|
for sub in ("", "noise"):
|
|
try:
|
|
fh = open(os.path.join(REC_DIR, parts[0], sub, parts[1]), "rb")
|
|
break
|
|
except OSError:
|
|
continue
|
|
if fh is None:
|
|
if parts[1] in arch_index(parts[0]):
|
|
return archive_proxy(h, parts[0], parts[1], dl)
|
|
return h.send(404, "not found", "text/plain")
|
|
with fh:
|
|
size = os.fstat(fh.fileno()).st_size
|
|
start, end = 0, size - 1
|
|
code = 200
|
|
rng = h.headers.get("Range")
|
|
if rng:
|
|
m = re.match(r"bytes=(\d*)-(\d*)$", rng.strip())
|
|
if not m or (m[1] == "" and m[2] == ""):
|
|
return h.send(416, "", "text/plain", {"Content-Range": f"bytes */{size}"})
|
|
if m[1] == "":
|
|
start = max(0, size - int(m[2]))
|
|
else:
|
|
start = int(m[1])
|
|
if m[2] != "":
|
|
end = min(int(m[2]), size - 1)
|
|
if start >= size or start > end:
|
|
return h.send(416, "", "text/plain", {"Content-Range": f"bytes */{size}"})
|
|
code = 206
|
|
length = end - start + 1
|
|
h.send_response(code)
|
|
h.send_header("Content-Type", "audio/mpeg")
|
|
h.send_header("Accept-Ranges", "bytes")
|
|
h.send_header("Content-Length", str(length))
|
|
h.send_header("Cache-Control", "public, max-age=86400")
|
|
if dl:
|
|
h.send_header("Content-Disposition", f'attachment; filename="{parts[1]}"')
|
|
if code == 206:
|
|
h.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
|
h.end_headers()
|
|
if h.command == "HEAD":
|
|
return
|
|
fh.seek(start)
|
|
remaining = length
|
|
try:
|
|
while remaining > 0:
|
|
chunk = fh.read(min(65536, remaining))
|
|
if not chunk:
|
|
break
|
|
h.wfile.write(chunk)
|
|
remaining -= len(chunk)
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
pass
|
|
|
|
|
|
def archive_proxy(h, day, name, dl):
|
|
"""Stream an archived recording from the home-server HDD (Range passed through)."""
|
|
host = urlparse(ARCHIVE_URL)
|
|
conn = http.client.HTTPConnection(host.hostname, host.port or 80, timeout=10)
|
|
hdrs = {"Range": h.headers["Range"]} if h.headers.get("Range") else {}
|
|
try:
|
|
conn.request(h.command if h.command == "HEAD" else "GET", f"/{day}/{quote(name)}", headers=hdrs)
|
|
r = conn.getresponse()
|
|
except OSError:
|
|
return h.send(502, "archive unreachable", "text/plain")
|
|
try:
|
|
if r.status not in (200, 206):
|
|
r.read()
|
|
return h.send(404 if r.status == 404 else 502, "not found in archive", "text/plain")
|
|
h.send_response(r.status)
|
|
for k in ("Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"):
|
|
if r.getheader(k):
|
|
h.send_header(k, r.getheader(k))
|
|
h.send_header("Cache-Control", "public, max-age=86400")
|
|
if dl:
|
|
h.send_header("Content-Disposition", f'attachment; filename="{name}"')
|
|
h.end_headers()
|
|
if h.command == "HEAD":
|
|
return
|
|
while True:
|
|
chunk = r.read(65536)
|
|
if not chunk:
|
|
break
|
|
h.wfile.write(chunk)
|
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
pass
|
|
finally:
|
|
conn.close()
|