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.
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/PKG-INFO +1 -1
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/pyproject.toml +1 -1
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/watcher.py +36 -10
- audio_transcode_watcher-0.5.1/tests/test_watcher.py +158 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.coderabbit.yaml +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.github/FUNDING.yml +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.github/dependabot.yml +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.github/workflows/dockerhub-description.yml +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.github/workflows/release.yml +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.github/workflows/stale.yml +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.github/workflows/tests.yml +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.gitignore +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/AGENTS.md +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/Dockerfile +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/LICENSE +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/README.md +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/SECURITY.md +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/config.example.yaml +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/docs/ROADMAP.md +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/docs/images/banner.svg +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/docs/images/icon.png +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/docs/images/icon.svg +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/__init__.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/config.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/encoder.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/lyrics.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/main.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/sync.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/utils.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/__init__.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/conftest.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/test_config.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/test_encoder.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/test_lyrics.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/test_sync.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tests/test_utils.py +0 -0
- {audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/tools/verify_sync.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
74
|
-
#
|
|
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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/.github/workflows/release.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/main.py
RENAMED
|
File without changes
|
{audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/sync.py
RENAMED
|
File without changes
|
{audio_transcode_watcher-0.5.0 → audio_transcode_watcher-0.5.1}/src/audio_transcode_watcher/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|