Skip to content

Commit 51fbfeb

Browse files
authored
fix: prevent encode-delete loop from spurious on_modified events (#12) (#13)
Add event cooldown to AudioSyncHandler: after processing a file, ignore on_modified events for 10 seconds. Spurious events are common on Docker bind mounts where .lrc sidecar creation changes directory mtime and triggers modify events on sibling audio files. Also guard on_modified against non-audio files by checking has_audio_extension before calling delete_outputs.
1 parent 6e485ba commit 51fbfeb

3 files changed

Lines changed: 195 additions & 11 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
55
[project]
66
name = "audio-transcode-watcher"
77

8-
version = "0.5.0"
8+
version = "0.5.1"
99

1010
description = "Watch a source folder and automatically transcode audio files to multiple formats"
1111
readme = "README.md"

‎src/audio_transcode_watcher/watcher.py‎

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
has_audio_extension,
2222
has_sidecar_extension,
2323
is_audio_file,
24+
nfc_path,
2425
remove_empty_dirs,
2526
walk_audio_files,
2627
)
@@ -29,26 +30,47 @@
2930
class AudioSyncHandler(FileSystemEventHandler):
3031
"""
3132
Watchdog event handler for audio file changes.
32-
33+
3334
Handles file creation, modification, deletion, and rename events.
3435
"""
35-
36+
37+
# Ignore on_modified events for files processed within this window.
38+
# Prevents encode-delete loops caused by spurious filesystem events
39+
# (e.g. directory mtime change after .lrc sidecar creation).
40+
_EVENT_COOLDOWN = 10.0
41+
3642
def __init__(self, config: Config) -> None:
3743
super().__init__()
3844
self.config = config
39-
45+
self._processed_at: dict[str, float] = {}
46+
self._processed_lock = threading.Lock()
47+
48+
def _mark_processed(self, path: str) -> None:
49+
"""Record that a file was just processed."""
50+
key = nfc_path(path)
51+
with self._processed_lock:
52+
self._processed_at[key] = time.monotonic()
53+
54+
def _is_cooling_down(self, path: str) -> bool:
55+
"""Check if a file was recently processed and should be skipped."""
56+
key = nfc_path(path)
57+
with self._processed_lock:
58+
ts = self._processed_at.get(key, 0.0)
59+
return (time.monotonic() - ts) < self._EVENT_COOLDOWN
60+
4061
def _process_later(self, path: str, force: bool = False) -> None:
4162
"""Process a file after a brief delay to coalesce rapid events."""
4263
if not is_audio_file(path):
4364
return
44-
65+
4566
# Small delay to coalesce burst events
4667
time.sleep(0.2)
47-
68+
4869
if safety_guard_active(self.config):
4970
return
50-
71+
5172
process_source_file(path, self.config, force=force, check_stable=True)
73+
self._mark_processed(path)
5274

5375
def on_created(self, event) -> None:
5476
"""Handle file creation (audio or sidecar)."""
@@ -64,14 +86,18 @@ def on_modified(self, event) -> None:
6486
"""Handle file modification (audio or sidecar)."""
6587
if event.is_directory:
6688
return
67-
89+
6890
if safety_guard_active(self.config):
6991
return
70-
92+
7193
if has_sidecar_extension(event.src_path):
7294
sync_sidecars(event.src_path, self.config)
73-
else:
74-
# Delete old outputs first, then re-encode
95+
elif has_audio_extension(event.src_path):
96+
# Skip spurious events for files we just finished processing.
97+
# Common on Docker bind mounts where .lrc creation triggers
98+
# a directory mtime change that emits modify for siblings.
99+
if self._is_cooling_down(event.src_path):
100+
return
75101
delete_outputs(event.src_path, self.config)
76102
self._process_later(event.src_path, force=True)
77103

‎tests/test_watcher.py‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Tests for the watcher module."""
2+
3+
import time
4+
from types import SimpleNamespace
5+
from unittest.mock import MagicMock, patch
6+
7+
import pytest
8+
9+
from audio_transcode_watcher.config import Config, OutputConfig
10+
from audio_transcode_watcher.watcher import AudioSyncHandler
11+
12+
13+
@pytest.fixture
14+
def handler(tmp_path):
15+
"""Create an AudioSyncHandler with a temp config."""
16+
src = tmp_path / "source"
17+
src.mkdir()
18+
out = tmp_path / "output"
19+
out.mkdir()
20+
config = Config(
21+
source_path=str(src),
22+
outputs=[
23+
OutputConfig(name="mp3", codec="mp3", path=str(out), bitrate="192k"),
24+
],
25+
)
26+
return AudioSyncHandler(config)
27+
28+
29+
def _event(src_path, is_directory=False, dest_path=None):
30+
"""Create a minimal event object."""
31+
ev = SimpleNamespace(src_path=src_path, is_directory=is_directory)
32+
if dest_path is not None:
33+
ev.dest_path = dest_path
34+
return ev
35+
36+
37+
class TestCooldown:
38+
"""Tests for the event cooldown mechanism."""
39+
40+
def test_not_cooling_down_initially(self, handler):
41+
assert not handler._is_cooling_down("/music/song.flac")
42+
43+
def test_cooling_down_after_mark(self, handler):
44+
handler._mark_processed("/music/song.flac")
45+
assert handler._is_cooling_down("/music/song.flac")
46+
47+
def test_cooldown_expires(self, handler):
48+
handler._mark_processed("/music/song.flac")
49+
# Monkey-patch the timestamp to simulate expiry
50+
key = "/music/song.flac"
51+
with handler._processed_lock:
52+
handler._processed_at[key] = time.monotonic() - handler._EVENT_COOLDOWN - 1
53+
assert not handler._is_cooling_down("/music/song.flac")
54+
55+
def test_cooldown_per_file(self, handler):
56+
handler._mark_processed("/music/song1.flac")
57+
assert handler._is_cooling_down("/music/song1.flac")
58+
assert not handler._is_cooling_down("/music/song2.flac")
59+
60+
61+
class TestOnModified:
62+
"""Tests for on_modified event handling."""
63+
64+
def test_ignores_directory_events(self, handler):
65+
with patch("audio_transcode_watcher.watcher.delete_outputs") as mock_del:
66+
handler.on_modified(_event("/music/subdir", is_directory=True))
67+
mock_del.assert_not_called()
68+
69+
@patch("audio_transcode_watcher.watcher.sync_sidecars")
70+
@patch("audio_transcode_watcher.watcher.safety_guard_active", return_value=False)
71+
def test_syncs_sidecar_on_modify(self, _safety, mock_sync, handler):
72+
handler.on_modified(_event("/music/source/song.lrc"))
73+
mock_sync.assert_called_once()
74+
75+
@patch("audio_transcode_watcher.watcher.process_source_file")
76+
@patch("audio_transcode_watcher.watcher.delete_outputs")
77+
@patch("audio_transcode_watcher.watcher.safety_guard_active", return_value=False)
78+
@patch("audio_transcode_watcher.watcher.is_audio_file", return_value=True)
79+
@patch("time.sleep")
80+
def test_skips_modify_during_cooldown(
81+
self, _sleep, _is_audio, _safety, mock_del, mock_proc, handler
82+
):
83+
"""The core bug fix: on_modified must not delete+re-encode during cooldown."""
84+
handler._mark_processed("/music/source/song.flac")
85+
handler.on_modified(_event("/music/source/song.flac"))
86+
mock_del.assert_not_called()
87+
mock_proc.assert_not_called()
88+
89+
@patch("audio_transcode_watcher.watcher.process_source_file")
90+
@patch("audio_transcode_watcher.watcher.delete_outputs")
91+
@patch("audio_transcode_watcher.watcher.safety_guard_active", return_value=False)
92+
@patch("audio_transcode_watcher.watcher.is_audio_file", return_value=True)
93+
@patch("time.sleep")
94+
def test_processes_modify_after_cooldown(
95+
self, _sleep, _is_audio, _safety, mock_del, mock_proc, handler
96+
):
97+
"""Real modifications (after cooldown) must still delete+re-encode."""
98+
# Mark processed long ago
99+
key = "/music/source/song.flac"
100+
with handler._processed_lock:
101+
handler._processed_at[key] = time.monotonic() - handler._EVENT_COOLDOWN - 1
102+
handler.on_modified(_event(key))
103+
mock_del.assert_called_once()
104+
mock_proc.assert_called_once()
105+
106+
@patch("audio_transcode_watcher.watcher.delete_outputs")
107+
@patch("audio_transcode_watcher.watcher.safety_guard_active", return_value=False)
108+
def test_ignores_non_audio_non_sidecar(self, _safety, mock_del, handler):
109+
"""Non-audio, non-sidecar files (e.g. .txt) must be ignored."""
110+
handler.on_modified(_event("/music/source/notes.txt"))
111+
mock_del.assert_not_called()
112+
113+
114+
class TestOnCreated:
115+
"""Tests for on_created event handling."""
116+
117+
def test_ignores_directory_events(self, handler):
118+
with patch("audio_transcode_watcher.watcher.sync_sidecars") as mock_sync:
119+
handler.on_created(_event("/music/subdir", is_directory=True))
120+
mock_sync.assert_not_called()
121+
122+
@patch("audio_transcode_watcher.watcher.sync_sidecars")
123+
def test_syncs_sidecar_on_create(self, mock_sync, handler):
124+
handler.on_created(_event("/music/source/song.lrc"))
125+
mock_sync.assert_called_once()
126+
127+
128+
class TestOnDeleted:
129+
"""Tests for on_deleted event handling."""
130+
131+
@patch("audio_transcode_watcher.watcher.delete_outputs")
132+
def test_deletes_outputs_for_audio(self, mock_del, handler):
133+
handler.on_deleted(_event("/music/source/song.flac"))
134+
mock_del.assert_called_once()
135+
136+
@patch("audio_transcode_watcher.watcher.delete_sidecars")
137+
def test_deletes_sidecars_for_lrc(self, mock_del, handler):
138+
handler.on_deleted(_event("/music/source/song.lrc"))
139+
mock_del.assert_called_once()
140+
141+
@patch("audio_transcode_watcher.watcher.delete_outputs")
142+
def test_ignores_non_audio(self, mock_del, handler):
143+
handler.on_deleted(_event("/music/source/notes.txt"))
144+
mock_del.assert_not_called()
145+
146+
147+
class TestProcessLater:
148+
"""Tests for _process_later marking cooldown."""
149+
150+
@patch("audio_transcode_watcher.watcher.process_source_file")
151+
@patch("audio_transcode_watcher.watcher.safety_guard_active", return_value=False)
152+
@patch("audio_transcode_watcher.watcher.is_audio_file", return_value=True)
153+
@patch("time.sleep")
154+
def test_marks_processed_after_encoding(
155+
self, _sleep, _is_audio, _safety, _proc, handler
156+
):
157+
handler._process_later("/music/source/song.flac")
158+
assert handler._is_cooling_down("/music/source/song.flac")

0 commit comments

Comments
 (0)