feat: name cut clips by wall-clock time; fix recording filename format
Cut downloads were named by byte offsets (`..._cut_740s-750s.flac`). They are now named by the actual recording time the slice covers, e.g. `20260523_22-31-30_22-32-30.flac` for a 22:31:30->22:32:30 cut of a recording started at 22:00:00. To make this reliable, the recording filename is now a fixed `%Y%m%d_%H%M%S` start-time format (`FILENAME_FORMAT`) shared by isr.py and web.py, replacing the user-configurable `filename_pattern` (web.py never reads config.ini, so a custom pattern could not be parsed back). web.py parses the start time out of the filename via `_recording_start()` and builds cut names with `_cut_filename()`. The DATE column now also comes from the filename (falling back to mtime only for non-standard names), since mtime is the last write, not the start. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+5
-23
@@ -142,7 +142,6 @@ class TestGetNextSplitTime:
|
||||
r._clock = fixed_clock(now)
|
||||
r.split_duration = split_minutes
|
||||
r.output_dir = cfg["output_directory"]
|
||||
r.filename_pattern = "%Y%m%d_%H%M%S"
|
||||
r.max_retries = 3
|
||||
r.retry_delay = 1
|
||||
r.file_format = "auto"
|
||||
@@ -184,7 +183,7 @@ class TestGetNextSplitTime:
|
||||
class TestGenerateFilename:
|
||||
"""Tests for BaseRecorder.generate_filename()."""
|
||||
|
||||
def _recorder(self, pattern: str, now: datetime, output_dir: str) -> isr.BaseRecorder:
|
||||
def _recorder(self, now: datetime, output_dir: str) -> isr.BaseRecorder:
|
||||
class _Rec(isr.BaseRecorder):
|
||||
def record(self): pass
|
||||
|
||||
@@ -198,28 +197,21 @@ class TestGenerateFilename:
|
||||
r._clock = fixed_clock(now)
|
||||
r.split_duration = 60
|
||||
r.output_dir = output_dir
|
||||
r.filename_pattern = pattern
|
||||
r.max_retries = 3
|
||||
r.retry_delay = 1
|
||||
r.file_format = "auto"
|
||||
return r
|
||||
|
||||
def test_basic_pattern(self, tmp_path):
|
||||
def test_fixed_format(self, tmp_path):
|
||||
# Filenames are always <YYYYMMDD>_<HHMMSS>.<ext> — the recording start.
|
||||
now = datetime(2024, 12, 25, 14, 30, 0)
|
||||
r = self._recorder("%Y%m%d_%H%M%S", now, str(tmp_path))
|
||||
r = self._recorder(now, str(tmp_path))
|
||||
name = r.generate_filename("mp3")
|
||||
assert name.endswith("20241225_143000.mp3")
|
||||
|
||||
def test_subdirectory_created(self, tmp_path):
|
||||
now = datetime(2024, 12, 25, 14, 30, 0)
|
||||
r = self._recorder("%Y/%m/%d/rec_%H%M%S", now, str(tmp_path))
|
||||
name = r.generate_filename("ogg")
|
||||
parent = Path(name).parent
|
||||
assert parent.exists()
|
||||
|
||||
def test_output_dir_prefix(self, tmp_path):
|
||||
now = datetime(2024, 1, 1, 0, 0, 0)
|
||||
r = self._recorder("%Y%m%d", now, str(tmp_path))
|
||||
r = self._recorder(now, str(tmp_path))
|
||||
name = r.generate_filename("wav")
|
||||
assert name.startswith(str(tmp_path))
|
||||
|
||||
@@ -236,7 +228,6 @@ class TestDetectFormat:
|
||||
"url": "http://example.com/stream",
|
||||
"output_directory": "/tmp",
|
||||
"split_minutes": 60,
|
||||
"filename_pattern": "%Y%m%d_%H%M%S",
|
||||
"max_retries": 1,
|
||||
"retry_delay_seconds": 0,
|
||||
"format": "auto",
|
||||
@@ -252,7 +243,6 @@ class TestDetectFormat:
|
||||
r._clock = datetime.now
|
||||
r.split_duration = 60
|
||||
r.output_dir = "/tmp"
|
||||
r.filename_pattern = "%Y%m%d_%H%M%S"
|
||||
r.max_retries = 1
|
||||
r.retry_delay = 0
|
||||
r.file_format = "auto"
|
||||
@@ -446,7 +436,6 @@ class TestRecorderManagerLoadConfig:
|
||||
[general]
|
||||
output_directory = {str(tmp_path / "recordings")}
|
||||
split_minutes = 30
|
||||
filename_pattern = test_%Y%m%d
|
||||
max_retries = 3
|
||||
retry_delay_seconds = 2
|
||||
log_level = WARNING
|
||||
@@ -491,7 +480,6 @@ format = ogg
|
||||
[general]
|
||||
output_directory = {str(tmp_path / "recordings")}
|
||||
split_minutes = 60
|
||||
filename_pattern = %Y%m%d_%H%M%S
|
||||
max_retries = 10
|
||||
retry_delay_seconds = 5
|
||||
log_level = WARNING
|
||||
@@ -501,7 +489,6 @@ log_file = {log_file}
|
||||
type = stream
|
||||
url = http://example.com/stream
|
||||
split_minutes = 15
|
||||
filename_pattern = custom_%Y%m%d
|
||||
"""
|
||||
cfg_path = tmp_path / "config.ini"
|
||||
cfg_path.write_text(config_text)
|
||||
@@ -512,7 +499,6 @@ filename_pattern = custom_%Y%m%d
|
||||
|
||||
rec = mgr.recorders[0]
|
||||
assert rec.split_duration == 15
|
||||
assert rec.filename_pattern == "custom_%Y%m%d"
|
||||
|
||||
def test_unknown_type_is_skipped(self, tmp_path):
|
||||
log_file = str(tmp_path / "test.log")
|
||||
@@ -558,7 +544,6 @@ class TestStreamRecorderRecord:
|
||||
"url": "http://example.com/stream",
|
||||
"output_directory": "", # overridden per-test with tmp_path
|
||||
"split_minutes": 60,
|
||||
"filename_pattern": "%Y%m%d_%H%M%S",
|
||||
"max_retries": 2,
|
||||
"retry_delay_seconds": 0,
|
||||
"format": fmt,
|
||||
@@ -610,7 +595,6 @@ class TestSoundcardRecorder:
|
||||
"format": "wav",
|
||||
"output_directory": str(tmp_path),
|
||||
"split_minutes": 60,
|
||||
"filename_pattern": "%Y%m%d_%H%M%S",
|
||||
"max_retries": 1,
|
||||
"retry_delay_seconds": 0,
|
||||
}
|
||||
@@ -639,7 +623,6 @@ class TestSoundcardRecorder:
|
||||
"format": "flac",
|
||||
"output_directory": str(tmp_path),
|
||||
"split_minutes": 60,
|
||||
"filename_pattern": "%Y%m%d_%H%M%S",
|
||||
"max_retries": 1,
|
||||
"retry_delay_seconds": 0,
|
||||
}
|
||||
@@ -662,7 +645,6 @@ class TestSoundcardRecorder:
|
||||
"format": "flac",
|
||||
"output_directory": str(tmp_path),
|
||||
"split_minutes": 60,
|
||||
"filename_pattern": "%Y%m%d_%H%M%S",
|
||||
"max_retries": 1,
|
||||
"retry_delay_seconds": 0,
|
||||
}
|
||||
|
||||
+31
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import math
|
||||
|
||||
from web import _loud_sections, _noise_floor_db
|
||||
from web import _cut_filename, _loud_sections, _noise_floor_db, _recording_start
|
||||
|
||||
WINDOW_DUR = 0.1 # 100 ms windows, as produced by WINDOW_SAMPLES at 48 kHz
|
||||
|
||||
@@ -99,3 +99,33 @@ def test_noise_floor_tracks_blocks_and_ignores_short_events():
|
||||
floor = _noise_floor_db(db, WINDOW_DUR)
|
||||
assert len(floor) == len(db)
|
||||
assert all(abs(f - quiet_db) < 1.0 for f in floor)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filename parsing / cut naming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_recording_start_parses_standard_name():
|
||||
from datetime import datetime
|
||||
assert _recording_start("20260523_220000") == datetime(2026, 5, 23, 22, 0, 0)
|
||||
|
||||
|
||||
def test_recording_start_rejects_nonstandard_name():
|
||||
assert _recording_start("radio1_20260523") is None
|
||||
assert _recording_start("notes") is None
|
||||
|
||||
|
||||
def test_cut_filename_uses_wall_clock_span():
|
||||
# Recording started 22:00:00; cut covers 22:31:30 → 22:32:30.
|
||||
name = _cut_filename("20260523_220000", ".flac", 1890, 1950)
|
||||
assert name == "20260523_22-31-30_22-32-30.flac"
|
||||
|
||||
|
||||
def test_cut_filename_rolls_over_the_hour():
|
||||
name = _cut_filename("20260523_220000", ".wav", 3590, 3661)
|
||||
assert name == "20260523_22-59-50_23-01-01.wav"
|
||||
|
||||
|
||||
def test_cut_filename_falls_back_for_nonstandard_name():
|
||||
name = _cut_filename("mixtape", ".mp3", 740, 750.4)
|
||||
assert name == "mixtape_cut_740s-750s.mp3"
|
||||
|
||||
Reference in New Issue
Block a user