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:
2026-07-29 21:29:57 +02:00
co-authored by Claude Opus 5
parent 0e7ea82da6
commit 138f6db0a5
6 changed files with 91 additions and 68 deletions
+6 -5
View File
@@ -641,8 +641,7 @@ class SoundcardRecorder(BaseRecorder):
def _open_output_file(self):
"""Open a new WAV or FLAC output file for recording."""
self._flush_buffer_to_file()
self.close_current_file()
self.close_current_file() # overridden below to flush first
self.current_filename = self.generate_filename(self.file_format)
self.current_file = _AudioFileWriter(
@@ -747,19 +746,21 @@ class RecorderManager:
config.read(self.config_file)
# Get general settings (defaults)
# Defaults every source inherits. Logging is read separately — it is
# a process-wide setting, not something a recorder ever looks at.
general = {
'output_directory': config.get('general', 'output_directory', fallback='recordings'),
'split_minutes': config.getint('general', 'split_minutes', fallback=60),
'max_retries': config.getint('general', 'max_retries', fallback=10),
'retry_delay_seconds': config.getint('general', 'retry_delay_seconds', fallback=5),
'log_level': config.get('general', 'log_level', fallback='INFO').upper(),
'log_file': config.get('general', 'log_file', fallback='recorder.log'),
}
self.output_dir = general['output_directory']
# Setup logging
self._setup_logging(general['log_level'], general['log_file'])
self._setup_logging(
config.get('general', 'log_level', fallback='INFO').upper(),
config.get('general', 'log_file', fallback='recorder.log'))
# Initialize shared audio system for soundcard recorders
self.audio_system = AudioSystem(self.logger)