audio-transcode-watcher 0.5.0__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/PKG-INFO +1 -1
  2. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/pyproject.toml +1 -1
  3. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/watcher.py +36 -10
  4. audio_transcode_watcher-0.5.1/tests/test_watcher.py +158 -0
  5. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.coderabbit.yaml +0 -0
  6. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.github/FUNDING.yml +0 -0
  7. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.github/dependabot.yml +0 -0
  8. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.github/workflows/dockerhub-description.yml +0 -0
  9. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.github/workflows/release.yml +0 -0
  10. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.github/workflows/stale.yml +0 -0
  11. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.github/workflows/tests.yml +0 -0
  12. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.gitignore +0 -0
  13. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/AGENTS.md +0 -0
  14. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/Dockerfile +0 -0
  15. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/LICENSE +0 -0
  16. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/README.md +0 -0
  17. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/SECURITY.md +0 -0
  18. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/config.example.yaml +0 -0
  19. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/docs/ROADMAP.md +0 -0
  20. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/docs/images/banner.svg +0 -0
  21. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/docs/images/icon.png +0 -0
  22. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/docs/images/icon.svg +0 -0
  23. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/__init__.py +0 -0
  24. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/config.py +0 -0
  25. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/encoder.py +0 -0
  26. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/lyrics.py +0 -0
  27. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/main.py +0 -0
  28. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/sync.py +0 -0
  29. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/utils.py +0 -0
  30. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/__init__.py +0 -0
  31. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/conftest.py +0 -0
  32. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/test_config.py +0 -0
  33. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/test_encoder.py +0 -0
  34. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/test_lyrics.py +0 -0
  35. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/test_sync.py +0 -0
  36. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/test_utils.py +0 -0
  37. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tools/verify_sync.py +0 -0
  38. {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: audio-transcode-watcher
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Watch a source folder and automatically transcode audio files to multiple formats
5
5
  Project-URL: Homepage, https://github.com/GeiserX/audio-transcode-watcher
6
6
  Project-URL: Repository, https://github.com/GeiserX/audio-transcode-watcher
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
  [project]
6
6
  name = "audio-transcode-watcher"
7
7
 
8
- version = "0.5.0"
8
+ version = "0.5.1"
9
9
 
10
10
  description = "Watch a source folder and automatically transcode audio files to multiple formats"
11
11
  readme = "README.md"
@@ -21,6 +21,7 @@ from .utils import (
21
21
  has_audio_extension,
22
22
  has_sidecar_extension,
23
23
  is_audio_file,
24
+ nfc_path,
24
25
  remove_empty_dirs,
25
26
  walk_audio_files,
26
27
  )
@@ -29,26 +30,47 @@ from .utils import (
29
30
  class AudioSyncHandler(FileSystemEventHandler):
30
31
  """
31
32
  Watchdog event handler for audio file changes.
32
-
33
+
33
34
  Handles file creation, modification, deletion, and rename events.
34
35
  """
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
+
36
42
  def __init__(self, config: Config) -> None:
37
43
  super().__init__()
38
44
  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
+
40
61
  def _process_later(self, path: str, force: bool = False) -> None:
41
62
  """Process a file after a brief delay to coalesce rapid events."""
42
63
  if not is_audio_file(path):
43
64
  return
44
-
65
+
45
66
  # Small delay to coalesce burst events
46
67
  time.sleep(0.2)
47
-
68
+
48
69
  if safety_guard_active(self.config):
49
70
  return
50
-
71
+
51
72
  process_source_file(path, self.config, force=force, check_stable=True)
73
+ self._mark_processed(path)
52
74
 
53
75
  def on_created(self, event) -> None:
54
76
  """Handle file creation (audio or sidecar)."""
@@ -64,14 +86,18 @@ class AudioSyncHandler(FileSystemEventHandler):
64
86
  """Handle file modification (audio or sidecar)."""
65
87
  if event.is_directory:
66
88
  return
67
-
89
+
68
90
  if safety_guard_active(self.config):
69
91
  return
70
-
92
+
71
93
  if has_sidecar_extension(event.src_path):
72
94
  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
75
101
  delete_outputs(event.src_path, self.config)
76
102
  self._process_later(event.src_path, force=True)
77
103
 
@@ -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")