Skip to content

Commit 089d788

Browse files
committed
fix: clean up legacy source ID media
1 parent 057b069 commit 089d788

6 files changed

Lines changed: 201 additions & 25 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## [Unreleased]
44

5+
- Fixed detection deletion responses reporting the generated filename instead of the legacy media file actually removed
6+
- Fixed automatic storage cleanup skipping audio and spectrogram files created during the source-ID filename transition, allowing upgraded stations to reclaim those legacy recordings
57
- Improved native updates on Raspberry Pis by stabilizing Docker base layers between releases and removing the backend compiler toolchain from the runtime image, reducing both routine downloads and slow SD-card extraction
68
- Fixed failed update checks exposing the exact installed commit to anonymous visitors through detailed GitHub error URLs; diagnostics remain available to signed-in owners and in logs
79
- Fixed authentication and settings temporary files being readable by other local users while sensitive content was still being written; private files now start owner-only

‎backend/core/db.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1355,8 +1355,8 @@ def get_cleanup_scan_batch(self, after_timestamp=None, after_id=None,
13551355
13561356
Returns:
13571357
List of dicts with id, common_name, confidence, timestamp,
1358-
extra (raw JSON string); fewer than ``limit`` rows signals
1359-
the end of the table.
1358+
extra (raw JSON string), and audio_source; fewer than ``limit``
1359+
rows signals the end of the table.
13601360
"""
13611361
where = _NO_FILTERS
13621362
params = []
@@ -1365,7 +1365,7 @@ def get_cleanup_scan_batch(self, after_timestamp=None, after_id=None,
13651365
params = [after_timestamp, after_timestamp, after_id]
13661366

13671367
query = f"""
1368-
SELECT id, common_name, confidence, timestamp, extra
1368+
SELECT id, common_name, confidence, timestamp, extra, audio_source
13691369
FROM detections
13701370
WHERE {where}
13711371
ORDER BY timestamp ASC, id ASC

‎backend/core/routes/detections.py‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -331,12 +331,9 @@ def delete_detection(detection_id):
331331
# Clean up associated files using shared utility
332332
delete_result = delete_detection_files(detection)
333333

334-
# Build files_deleted list for response
335-
files_deleted = []
336-
if delete_result['deleted_audio']:
337-
files_deleted.append(detection['audio_filename'])
338-
if delete_result['deleted_spectrogram']:
339-
files_deleted.append(detection['spectrogram_filename'])
334+
# Report the resolved names that were actually removed. These can differ
335+
# from the canonical DB filenames for source-ID and colon-pattern files.
336+
files_deleted = delete_result['deleted_filenames']
340337

341338
logger.info("Detection deleted with files", extra={
342339
'detection_id': detection_id,

‎backend/core/storage_manager.py‎

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,15 @@ def _resolve_path_with_legacy_fallback(filename, directory):
9898
return path # Return original path even if it doesn't exist
9999

100100

101-
def _detection_filenames(detection):
102-
"""Build the dash-pattern filenames for a detection record."""
101+
def _detection_filename_candidates(detection):
102+
"""Build ordered dash-pattern filename candidates for a detection.
103+
104+
Source labels are frozen into ``extra`` for current files. During the
105+
brief transition to multi-source recording, files instead used the raw
106+
``audio_source`` id (for example, ``source_0``) and those rows have no
107+
saved label. Some imported legacy rows can still be unsuffixed despite
108+
carrying an audio source, so keep that as the final fallback.
109+
"""
103110
extra = detection.get('extra', {})
104111
if isinstance(extra, str):
105112
# Only the source_label matters here, and most rows don't have one —
@@ -112,14 +119,36 @@ def _detection_filenames(detection):
112119
else:
113120
extra = {}
114121
source_label = extra.get('source_label')
115-
return build_detection_filenames(
116-
detection['common_name'],
117-
detection['confidence'],
118-
detection['timestamp'],
119-
audio_source=source_label or None
122+
if source_label:
123+
source_suffixes = (source_label,)
124+
elif detection.get('audio_source'):
125+
source_suffixes = (detection['audio_source'], None)
126+
else:
127+
source_suffixes = (None,)
128+
129+
return tuple(
130+
build_detection_filenames(
131+
detection['common_name'],
132+
detection['confidence'],
133+
detection['timestamp'],
134+
audio_source=source_suffix,
135+
)
136+
for source_suffix in source_suffixes
120137
)
121138

122139

140+
def _resolve_detection_path(filename_candidates, key, directory):
141+
"""Return the first existing path for one detection media type."""
142+
first_path = None
143+
for filenames in filename_candidates:
144+
path = _resolve_path_with_legacy_fallback(filenames[key], directory)
145+
if first_path is None:
146+
first_path = path
147+
if os.path.exists(path):
148+
return path
149+
return first_path
150+
151+
123152
def get_detection_files(detection):
124153
"""Get full file paths for a detection record.
125154
@@ -132,11 +161,13 @@ def get_detection_files(detection):
132161
Returns:
133162
dict with audio_path and spectrogram_path
134163
"""
135-
filenames = _detection_filenames(detection)
164+
filename_candidates = _detection_filename_candidates(detection)
136165

137166
return {
138-
'audio_path': _resolve_path_with_legacy_fallback(filenames['audio_filename'], EXTRACTED_AUDIO_DIR),
139-
'spectrogram_path': _resolve_path_with_legacy_fallback(filenames['spectrogram_filename'], SPECTROGRAM_DIR)
167+
'audio_path': _resolve_detection_path(
168+
filename_candidates, 'audio_filename', EXTRACTED_AUDIO_DIR),
169+
'spectrogram_path': _resolve_detection_path(
170+
filename_candidates, 'spectrogram_filename', SPECTROGRAM_DIR),
140171
}
141172

142173

@@ -166,9 +197,11 @@ def scan(directory):
166197
def _has_files_on_disk(detection, audio_names, spectrogram_names):
167198
"""Whether any of the detection's files (dash or legacy pattern) exist
168199
in the normalized directory snapshots from _disk_filename_sets()."""
169-
filenames = _detection_filenames(detection)
170-
return (filenames['audio_filename'] in audio_names
171-
or filenames['spectrogram_filename'] in spectrogram_names)
200+
return any(
201+
filenames['audio_filename'] in audio_names
202+
or filenames['spectrogram_filename'] in spectrogram_names
203+
for filenames in _detection_filename_candidates(detection)
204+
)
172205

173206

174207
def delete_detection_files(detection):
@@ -178,12 +211,14 @@ def delete_detection_files(detection):
178211
detection: dict with common_name, confidence, timestamp
179212
180213
Returns:
181-
dict with deleted_audio, deleted_spectrogram, bytes_freed
214+
dict with deleted_audio, deleted_spectrogram, deleted_filenames,
215+
and bytes_freed
182216
"""
183217
paths = get_detection_files(detection)
184218
result = {
185219
'deleted_audio': False,
186220
'deleted_spectrogram': False,
221+
'deleted_filenames': [],
187222
'bytes_freed': 0
188223
}
189224

@@ -194,6 +229,7 @@ def delete_detection_files(detection):
194229
size = os.path.getsize(audio_path)
195230
os.remove(audio_path)
196231
result['deleted_audio'] = True
232+
result['deleted_filenames'].append(os.path.basename(audio_path))
197233
result['bytes_freed'] += size
198234
except OSError as e:
199235
logger.warning("Failed to delete audio file", extra={
@@ -208,6 +244,8 @@ def delete_detection_files(detection):
208244
size = os.path.getsize(spectrogram_path)
209245
os.remove(spectrogram_path)
210246
result['deleted_spectrogram'] = True
247+
result['deleted_filenames'].append(
248+
os.path.basename(spectrogram_path))
211249
result['bytes_freed'] += size
212250
except OSError as e:
213251
logger.warning("Failed to delete spectrogram file", extra={

‎backend/tests/api/test_detections_api.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,58 @@ def test_delete_detection_removes_files(self, api_client, real_db_manager):
611611
assert not os.path.exists(audio_file)
612612
assert not os.path.exists(spectrogram_file)
613613

614+
def test_delete_reports_resolved_legacy_source_filenames(
615+
self, api_client, real_db_manager):
616+
"""The response names the source-ID files it actually removes."""
617+
with tempfile.TemporaryDirectory() as tmpdir:
618+
audio_dir = os.path.join(tmpdir, 'audio')
619+
spectrogram_dir = os.path.join(tmpdir, 'spectrograms')
620+
os.makedirs(audio_dir)
621+
os.makedirs(spectrogram_dir)
622+
623+
timestamp = '2024-01-15T10:30:00'
624+
detection_id = real_db_manager.insert_detection({
625+
'timestamp': timestamp,
626+
'group_timestamp': timestamp,
627+
'common_name': 'American Robin',
628+
'scientific_name': 'Turdus migratorius',
629+
'confidence': 0.8500,
630+
'latitude': 40.7128,
631+
'longitude': -74.0060,
632+
'cutoff': 0.5,
633+
'sensitivity': 0.75,
634+
'overlap': 0.25,
635+
'extra': {},
636+
'audio_source': 'source_0',
637+
})
638+
639+
from core.utils import build_detection_filenames
640+
641+
filenames = build_detection_filenames(
642+
'American Robin', 0.8500, timestamp,
643+
audio_source='source_0')
644+
audio_file = os.path.join(
645+
audio_dir, filenames['audio_filename'])
646+
spectrogram_file = os.path.join(
647+
spectrogram_dir, filenames['spectrogram_filename'])
648+
for path in (audio_file, spectrogram_file):
649+
with open(path, 'w') as f:
650+
f.write('media data')
651+
652+
with patch('core.auth.is_authenticated', return_value=True), \
653+
patch('core.storage_manager.EXTRACTED_AUDIO_DIR', audio_dir), \
654+
patch('core.storage_manager.SPECTROGRAM_DIR', spectrogram_dir):
655+
response = api_client.delete(
656+
f'/api/detections/{detection_id}')
657+
658+
assert response.status_code == 200
659+
assert response.get_json()['files_deleted'] == [
660+
filenames['audio_filename'],
661+
filenames['spectrogram_filename'],
662+
]
663+
assert not os.path.exists(audio_file)
664+
assert not os.path.exists(spectrogram_file)
665+
614666

615667
class TestDetectionsDatabaseMethods:
616668
"""Tests for the underlying database methods."""

‎backend/tests/test_storage_manager.py‎

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,8 @@ def test_keyset_walk_covers_every_row_once(self, populated_db_for_cleanup):
248248
keys = [(d['timestamp'], d['id']) for d in seen]
249249
assert keys == sorted(keys)
250250

251-
def test_returns_extra_column(self, test_db_manager):
252-
"""Scan rows include extra for filename reconstruction."""
251+
def test_returns_filename_metadata(self, test_db_manager):
252+
"""Scan rows include source metadata for filename reconstruction."""
253253
import json
254254

255255
base_time = datetime(2024, 1, 15, 10, 0, 0)
@@ -269,6 +269,7 @@ def test_returns_extra_column(self, test_db_manager):
269269
if isinstance(extra, str):
270270
extra = json.loads(extra)
271271
assert extra.get('source_label') == 'Backyard_Mic'
272+
assert detection['audio_source'] == 'alsa_input.usb-test'
272273

273274

274275
class TestGetDiskUsage:
@@ -328,13 +329,64 @@ def test_constructs_paths_with_source_label(self):
328329
'confidence': 0.85,
329330
'timestamp': '2024-01-15T10:30:00',
330331
'extra': '{"source_label": "Backyard_Mic"}',
332+
'audio_source': 'source_0',
331333
}
332334

333335
paths = get_detection_files(detection)
334336

335337
assert paths['audio_path'].endswith('_Backyard_Mic.mp3')
336338
assert paths['spectrogram_path'].endswith('_Backyard_Mic.webp')
337339

340+
def test_falls_back_to_legacy_source_id(self, storage_dirs):
341+
"""Rows from the source-ID transition resolve their suffixed files."""
342+
audio_dir, spectrogram_dir = storage_dirs
343+
344+
from core.utils import build_detection_filenames
345+
346+
names = build_detection_filenames(
347+
'Test Bird', 0.85, '2024-01-15T10:30:00',
348+
audio_source='source_0')
349+
legacy_audio = os.path.join(audio_dir, names['audio_filename'])
350+
legacy_spectrogram = os.path.join(
351+
spectrogram_dir, names['spectrogram_filename'])
352+
with open(legacy_audio, 'w') as f:
353+
f.write('audio')
354+
with open(legacy_spectrogram, 'w') as f:
355+
f.write('spectrogram')
356+
357+
from core.storage_manager import get_detection_files
358+
359+
paths = get_detection_files({
360+
'common_name': 'Test Bird',
361+
'confidence': 0.85,
362+
'timestamp': '2024-01-15T10:30:00',
363+
'extra': '{}',
364+
'audio_source': 'source_0',
365+
})
366+
367+
assert paths['audio_path'] == legacy_audio
368+
assert paths['spectrogram_path'] == legacy_spectrogram
369+
370+
def test_source_id_row_can_fall_back_to_unsuffixed_file(self, storage_dirs):
371+
"""Imported rows with a source id can still own unsuffixed files."""
372+
audio_dir, _ = storage_dirs
373+
unsuffixed_audio = os.path.join(
374+
audio_dir, 'Test_Bird_85_2024-01-15-birdnet-10-30-00.mp3')
375+
with open(unsuffixed_audio, 'w') as f:
376+
f.write('audio')
377+
378+
from core.storage_manager import get_detection_files
379+
380+
paths = get_detection_files({
381+
'common_name': 'Test Bird',
382+
'confidence': 0.85,
383+
'timestamp': '2024-01-15T10:30:00',
384+
'extra': '{}',
385+
'audio_source': 'source_0',
386+
})
387+
388+
assert paths['audio_path'] == unsuffixed_audio
389+
338390
def test_fallback_to_legacy_colon_pattern(self, storage_dirs):
339391
"""Should fall back to legacy colon-pattern files if dash-pattern not found."""
340392
audio_dir, spectrogram_dir = storage_dirs
@@ -453,6 +505,7 @@ def test_deletes_existing_files(self, storage_dirs):
453505

454506
assert result['deleted_audio']
455507
assert result['deleted_spectrogram']
508+
assert result['deleted_filenames'] == ['test.mp3', 'test.webp']
456509
assert result['bytes_freed'] == 1500
457510
assert not os.path.exists(audio_file)
458511
assert not os.path.exists(spectrogram_file)
@@ -475,6 +528,7 @@ def test_handles_missing_files_gracefully(self):
475528

476529
assert not result['deleted_audio']
477530
assert not result['deleted_spectrogram']
531+
assert result['deleted_filenames'] == []
478532
assert result['bytes_freed'] == 0
479533

480534

@@ -574,6 +628,39 @@ def test_cleanup_resolves_multi_source_filenames(self, test_db_manager, storage_
574628
assert result['skipped_missing'] == 0
575629
assert result['files_deleted'] == 10 # 70 - 60 protected
576630

631+
def test_cleanup_deletes_legacy_source_id_files(self, test_db_manager,
632+
storage_dirs):
633+
"""Cleanup finds files created before source-label filenames."""
634+
audio_dir, spectrogram_dir = storage_dirs
635+
636+
from core.utils import build_detection_filenames
637+
638+
for i in range(3):
639+
timestamp = f'2024-01-15T10:0{i}:00'
640+
detection = make_detection(
641+
timestamp, extra={}, audio_source='source_0')
642+
test_db_manager.insert_detection(detection)
643+
names = build_detection_filenames(
644+
'Test Bird', 0.85, timestamp, audio_source='source_0')
645+
for name, directory in (
646+
(names['audio_filename'], audio_dir),
647+
(names['spectrogram_filename'], spectrogram_dir)):
648+
with open(os.path.join(directory, name), 'wb') as f:
649+
f.write(b'x' * 100)
650+
651+
with patch('core.storage_manager.get_disk_usage',
652+
return_value=mock_disk_usage_needing(10 * 1024**3)):
653+
from core.storage_manager import cleanup_storage
654+
655+
result = cleanup_storage(
656+
test_db_manager, target_percent=80,
657+
keep_per_species=0, keep_recent_per_species=0)
658+
659+
assert result['skipped_missing'] == 0
660+
assert result['files_deleted'] == 3
661+
assert len(os.listdir(audio_dir)) == 0
662+
assert len(os.listdir(spectrogram_dir)) == 0
663+
577664
def test_cleanup_deletes_legacy_colon_files(self, test_db_manager, storage_dirs):
578665
"""Rows whose files still use the legacy colon pattern are found via
579666
the normalized directory snapshot and deleted through the legacy

0 commit comments

Comments
 (0)