refactor: one Range-capable file sender; HEAD support; assorted hygiene
web.py * _download and _stream each had their own copy loop; _download had no Range support at all, so an interrupted download of a multi-hundred-MB recording had to restart. Both now go through _serve_bytes(), which keeps _stream's live-header `prefix` trick (the patched bytes are the same length as the header they replace, so Range math is unaffected). * do_HEAD answers / , /stream/ and /download/ as a body-less do_GET. /api/clip and /api/cut return 405 instead — generating a clip or shelling out to ffmpeg only to discard the result is worse than not answering. * _api_delete drops the deleted file's _DURATION_CACHE entry, which was never evicted. * WAV durations are rounded to 2dp like FLAC's already were. isr.py * _open_output_file flushed the buffer and then called close_current_file(), which is overridden to flush again. * log_level/log_file were merged into the dict copied into every source's config; no recorder reads them. Read them where they are used. webui.html * scoreOrder() is memoised on the section array's identity — U/I otherwise re-sorted a whole day (thousands of sections) per keypress. Verified with a byte-level harness: full/partial/open-ended/unsatisfiable ranges byte-identical to disk, download Range, HEAD on all three supported paths returning headers with an empty body, 405 on /api/clip, and live-header patching still correct both whole-file and for a range straddling the patched/disk boundary. Not done from the audit: BaseRecorder.generate_filename's mkdir per file open. It runs once per split (once an hour by default), so "once at startup suffices" was not worth a behaviour change. `nul` stays in .gitignore — it guards against a Windows shell artifact on a Windows box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -281,7 +281,7 @@ def _get_audio_duration(path: Path):
|
||||
if ext == '.wav':
|
||||
try:
|
||||
with wave.open(str(path), 'rb') as wf:
|
||||
return wf.getnframes() / wf.getframerate()
|
||||
return round(wf.getnframes() / wf.getframerate(), 2)
|
||||
except Exception:
|
||||
return None
|
||||
if SOUNDFILE_AVAILABLE and ext in ('.flac', '.ogg', '.opus'):
|
||||
@@ -643,6 +643,16 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self._send(404, b'Not found', 'text/plain')
|
||||
|
||||
def do_HEAD(self):
|
||||
"""HEAD for the page and the audio files — some players probe before
|
||||
fetching. Clips and cuts are generated on demand, so answering HEAD
|
||||
for those would do all the work and throw the result away."""
|
||||
p = urlparse(self.path).path
|
||||
if p == '/' or p.startswith('/download/') or p.startswith('/stream/'):
|
||||
self.do_GET()
|
||||
else:
|
||||
self._send(405, b'Method Not Allowed', 'text/plain')
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
qs = parse_qs(parsed.query)
|
||||
@@ -757,20 +767,65 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
self._send(200, b'{"active":[]}', 'application/json')
|
||||
|
||||
def _serve_bytes(self, path: Path, size: int, content_type: str,
|
||||
prefix: bytes = b'', no_store: bool = False,
|
||||
disposition: str = None):
|
||||
"""Serve a file with HTTP Range support.
|
||||
|
||||
`prefix` replaces the first len(prefix) bytes of the file on the way
|
||||
out — that is how in-progress recordings get a header patched to the
|
||||
duration captured so far. It is the same length as the header it
|
||||
replaces, so all offsets and Range math stay valid.
|
||||
"""
|
||||
range_header = self.headers.get('Range', '')
|
||||
m = re.match(r'bytes=(\d+)-(\d*)', range_header) if range_header else None
|
||||
|
||||
if m:
|
||||
start = int(m.group(1))
|
||||
end = int(m.group(2)) if m.group(2) else size - 1
|
||||
end = min(end, size - 1)
|
||||
if start > end or start >= size:
|
||||
self._send(416, b'Range Not Satisfiable', 'text/plain')
|
||||
return
|
||||
length = end - start + 1
|
||||
self.send_response(206)
|
||||
self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
|
||||
else:
|
||||
start, length = 0, size
|
||||
self.send_response(200)
|
||||
|
||||
self.send_header('Content-Type', content_type)
|
||||
self.send_header('Content-Length', str(length))
|
||||
self.send_header('Accept-Ranges', 'bytes')
|
||||
if disposition:
|
||||
self.send_header('Content-Disposition', disposition)
|
||||
if no_store:
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.end_headers()
|
||||
|
||||
if self.command == 'HEAD':
|
||||
return
|
||||
|
||||
with open(path, 'rb') as fh:
|
||||
sent = 0
|
||||
if start < len(prefix):
|
||||
head = prefix[start:start + length]
|
||||
self.wfile.write(head)
|
||||
sent = len(head)
|
||||
if sent < length:
|
||||
fh.seek(start + sent)
|
||||
# Bound the copy: the file may grow while we serve it, and
|
||||
# writing more than Content-Length desyncs keep-alive.
|
||||
self._copy_to_response(fh, length - sent)
|
||||
|
||||
def _download(self, filename: str):
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
return
|
||||
|
||||
size = path.stat().st_size
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/octet-stream')
|
||||
self.send_header('Content-Disposition', f'attachment; filename="{path.name}"')
|
||||
self.send_header('Content-Length', str(size))
|
||||
self.end_headers()
|
||||
|
||||
with open(path, 'rb') as fh:
|
||||
self._copy_to_response(fh, size)
|
||||
# Range support matters here too: these files run to hundreds of MB
|
||||
# and an interrupted download would otherwise have to restart.
|
||||
self._serve_bytes(path, path.stat().st_size, 'application/octet-stream',
|
||||
disposition=f'attachment; filename="{path.name}"')
|
||||
|
||||
def _stream(self, filename: str):
|
||||
"""Serve audio for inline playback with HTTP Range support.
|
||||
@@ -795,54 +850,7 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
elif ext == '.flac':
|
||||
prefix = _live_flac_header(path, size) or b''
|
||||
|
||||
range_header = self.headers.get('Range', '')
|
||||
m = re.match(r'bytes=(\d+)-(\d*)', range_header) if range_header else None
|
||||
|
||||
if m:
|
||||
start = int(m.group(1))
|
||||
end = int(m.group(2)) if m.group(2) else size - 1
|
||||
end = min(end, size - 1)
|
||||
if start > end or start >= size:
|
||||
self._send(416, b'Range Not Satisfiable', 'text/plain')
|
||||
return
|
||||
length = end - start + 1
|
||||
|
||||
self.send_response(206)
|
||||
self.send_header('Content-Type', content_type)
|
||||
self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
|
||||
self.send_header('Content-Length', str(length))
|
||||
self.send_header('Accept-Ranges', 'bytes')
|
||||
if is_active:
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.end_headers()
|
||||
|
||||
with open(path, 'rb') as fh:
|
||||
sent = 0
|
||||
if start < len(prefix):
|
||||
head = prefix[start:start + length]
|
||||
self.wfile.write(head)
|
||||
sent = len(head)
|
||||
if sent < length:
|
||||
fh.seek(start + sent)
|
||||
self._copy_to_response(fh, length - sent)
|
||||
else:
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', content_type)
|
||||
self.send_header('Content-Length', str(size))
|
||||
self.send_header('Accept-Ranges', 'bytes')
|
||||
if is_active:
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.end_headers()
|
||||
|
||||
with open(path, 'rb') as fh:
|
||||
if prefix:
|
||||
self.wfile.write(prefix)
|
||||
fh.seek(len(prefix))
|
||||
self._copy_to_response(fh, size - len(prefix))
|
||||
else:
|
||||
# Bound the copy: the file may grow while we serve it, and
|
||||
# writing more than Content-Length desyncs keep-alive.
|
||||
self._copy_to_response(fh, size)
|
||||
self._serve_bytes(path, size, content_type, prefix=prefix, no_store=is_active)
|
||||
|
||||
def _api_storage(self):
|
||||
# 'used' is computed client-side from the file list; walking the whole
|
||||
@@ -885,6 +893,9 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with _DURATION_CACHE_LOCK:
|
||||
_DURATION_CACHE.pop(filename, None)
|
||||
|
||||
self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json')
|
||||
|
||||
def _api_cut(self, qs):
|
||||
@@ -1119,7 +1130,8 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self.send_header('Content-Type', content_type)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
if self.command != 'HEAD':
|
||||
self.wfile.write(body)
|
||||
|
||||
def _json_err(self, code: int, msg: str):
|
||||
self._send(code, json.dumps({'error': msg}).encode('utf-8'), 'application/json')
|
||||
|
||||
Reference in New Issue
Block a user