Skip to content

Commit 2e8c514

Browse files
committed
chore: release 0.6.7 - Latest Observation polish and recording resilience
1 parent f62900b commit 2e8c514

13 files changed

Lines changed: 346 additions & 215 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ AGENTS.md
3838
# Ignore claude code settings
3939
.claude/
4040

41+
# Ignore codex settings
42+
.codex/
43+
4144
# Ignore VS Code settings
4245
.vscode/
4346

CHANGELOG.md

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

33
## [Unreleased]
44

5+
## [0.6.7] - 2026-05-03
6+
57
- Fixed audio recordings getting stuck in an infinite reprocess loop when post-analysis steps (audio extraction, spectrogram generation, BirdWeather upload, etc.) raised — the same WAV would be re-collected on the next scan and retried forever, spamming logs with duplicate "Bird detected" lines and amplifying the FD pressure that caused the failure. The processing loop now isolates per-file and per-detection failures and always removes the WAV in a `finally` block (#46)
68
- Added file descriptor exhaustion diagnostics — when an EMFILE/ENFILE error is detected anywhere in the recording or processing pipeline, the next log line dumps the FD limit, current FD count, a sample of open FDs with their `/proc/self/fd` targets, and active child PIDs (catches zombie ffmpeg/sox processes holding pipes via leaked refs). Rate-limited per stage. Errno detection walks `__cause__`/`__context__`/`args` plus urllib3-style `.reason`, so EMFILE buried inside a `requests.ConnectionError(MaxRetryError(...))` chain is caught
9+
- Added recency-based recording protection — the latest N recordings per species are now retained alongside the existing top-N-by-confidence rule via union, so frequent recent activity isn't lost when its confidences fall below the top-N cut
10+
- Hid the Bird Activity Overview reverse toggle on days with no detections, since reversing an empty list is a no-op
11+
- Replaced the camera icon on bird detail pages with an upload arrow that animates open on hover to reveal "Upload custom image"
12+
- Redesigned the dashboard's Latest Observation card — bird image and text stack now sit side-by-side instead of vertically; the scientific name is shown again as a link to the bird's detail page, and the timestamp and confidence link to the Table view; long species names wrap to a second line instead of being clipped mid-word; a thin vertical divider separates the bird identity area from the spectrogram, which sits visually centered between the divider and the card edge
13+
- Polished the Latest Observation live spectrogram rendering — palette now matches the static spectrogram's `Greens_r` style (light background, dark green peaks), the canvas renders at 2× internal resolution for sharper downsampled output, and each new column is drawn as a horizontal gradient from the previous frame's intensities so time-axis transitions are continuous rather than stepped
714

815
## [0.6.6] - 2026-04-19
916

CLAUDE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ backend/
6767
deployment/ # Runtime service scripts, Icecast streaming
6868
scripts/ # lint.sh, install-tests/ (BATS)
6969
docs/ # ARCHITECTURE, INSTALLATION, PRIVACY
70-
internal_docs/ # Planning notes (workflow, reviews, design docs)
70+
internal_docs/ # Planning notes (workflow, reviews, design docs) — gitignored, do not commit
7171
```
7272

7373
`AGENTS.md` is a symlink to this file.
@@ -97,3 +97,7 @@ Three test suites cover backend, frontend, and install scripts. Each has its own
9797
## Branch Sync Rules
9898

9999
Before syncing dev to staging or main, always run `./build.sh` first and confirm it passes.
100+
101+
## Git Hygiene
102+
103+
Never bypass `.gitignore` (no `git add -f`, no explicit-path force-staging) without explicit user approval. If a file genuinely needs to be in the repo, change the ignore rule instead of overriding it for one file — silently force-adding leaves a tracked file behind that the ignore pattern can't clean up later.

backend/config/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def _get_positive_int_env(name: str, default: int) -> int:
4848
"trigger_percent": 85,
4949
"target_percent": 80,
5050
"keep_per_species": 60,
51+
"keep_recent_per_species": 16,
5152
"check_interval_minutes": 30
5253
},
5354
"updates": {

backend/core/db.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -843,24 +843,24 @@ def get_species_counts(self):
843843

844844
return {row['common_name']: row['count'] for row in results}
845845

846-
def get_cleanup_candidates(self, keep_per_species=60, limit=None):
846+
def get_cleanup_candidates(self, keep_per_species=60, keep_recent_per_species=16, limit=None):
847847
"""Get detections eligible for cleanup, oldest first.
848848
849-
For each species, keeps the top N recordings by confidence.
850-
Only returns recordings beyond the top N for each species.
849+
For each species, protects the union of two sets:
850+
- Top N by confidence (keep_per_species)
851+
- Most recent N by timestamp (keep_recent_per_species)
852+
A recording is a candidate only if it falls outside both sets.
851853
852854
Args:
853-
keep_per_species: Number of top recordings to keep per species (by confidence)
855+
keep_per_species: Top recordings to keep per species by confidence
856+
keep_recent_per_species: Most recent recordings to keep per species
854857
limit: Optional max number of records to return
855858
856859
Returns:
857860
List of dicts with: id, common_name, confidence, timestamp,
858861
audio_source, extra (raw JSON string)
859862
Ordered by timestamp ASC (oldest first)
860863
"""
861-
# Use window function to rank recordings within each species by confidence
862-
# Only return recordings ranked beyond keep_per_species
863-
# LIMIT is parameterized using -1 for unlimited (SQLite treats negative LIMIT as no limit)
864864
query = """
865865
WITH RankedDetections AS (
866866
SELECT
@@ -873,12 +873,16 @@ def get_cleanup_candidates(self, keep_per_species=60, limit=None):
873873
ROW_NUMBER() OVER (
874874
PARTITION BY common_name
875875
ORDER BY confidence DESC
876-
) as confidence_rank
876+
) as confidence_rank,
877+
ROW_NUMBER() OVER (
878+
PARTITION BY common_name
879+
ORDER BY timestamp DESC
880+
) as recency_rank
877881
FROM detections
878882
)
879883
SELECT id, common_name, confidence, timestamp, audio_source, extra
880884
FROM RankedDetections
881-
WHERE confidence_rank > ?
885+
WHERE confidence_rank > ? AND recency_rank > ?
882886
ORDER BY timestamp ASC
883887
LIMIT ?
884888
"""
@@ -888,13 +892,14 @@ def get_cleanup_candidates(self, keep_per_species=60, limit=None):
888892

889893
with self.get_db_connection() as conn:
890894
cur = conn.cursor()
891-
cur.execute(query, (keep_per_species, limit_param))
895+
cur.execute(query, (keep_per_species, keep_recent_per_species, limit_param))
892896
results = cur.fetchall()
893897

894898
candidates = [dict(row) for row in results]
895899

896900
logger.debug("Cleanup candidates retrieved", extra={
897901
'keep_per_species': keep_per_species,
902+
'keep_recent_per_species': keep_recent_per_species,
898903
'candidates_count': len(candidates)
899904
})
900905

backend/core/storage_manager.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
Key features:
88
- Triggers cleanup when disk usage exceeds trigger_percent (default: 80%)
99
- Frees space until usage drops to target_percent (default: 70%)
10-
- Protects species with fewer than min_recordings_per_species (default: 60)
10+
- Protects the top N recordings per species by confidence (default: 60)
11+
- Protects the latest N recordings per species by timestamp (default: 16)
1112
- Deletes oldest files first
1213
- Preserves all database records
1314
"""
@@ -39,6 +40,7 @@ def _get_storage_config() -> dict:
3940
'trigger_percent': storage.get('trigger_percent', 85),
4041
'target_percent': storage.get('target_percent', 80),
4142
'keep_per_species': storage.get('keep_per_species', 60),
43+
'keep_recent_per_species': storage.get('keep_recent_per_species', 16),
4244
'check_interval_minutes': storage.get('check_interval_minutes', 1440),
4345
}
4446

@@ -189,32 +191,38 @@ def delete_detection_files(detection):
189191
return result
190192

191193

192-
def estimate_deletable_size(db_manager, keep_per_species=None):
194+
def estimate_deletable_size(db_manager, keep_per_species=None, keep_recent_per_species=None):
193195
"""Estimate total size of files that could be deleted.
194196
195197
Args:
196198
db_manager: DatabaseManager instance
197-
keep_per_species: Number of recordings to keep per species
199+
keep_per_species: Top recordings to keep per species by confidence
200+
keep_recent_per_species: Most recent recordings to keep per species
198201
199202
Returns:
200203
Tuple of (estimated_bytes, candidate_count)
201204
"""
205+
config = _get_storage_config()
202206
if keep_per_species is None:
203-
keep_per_species = _get_storage_config()['keep_per_species']
207+
keep_per_species = config['keep_per_species']
208+
if keep_recent_per_species is None:
209+
keep_recent_per_species = config['keep_recent_per_species']
204210

205-
# Get candidates (without limit to count all)
206-
candidates = db_manager.get_cleanup_candidates(keep_per_species=keep_per_species)
211+
candidates = db_manager.get_cleanup_candidates(
212+
keep_per_species=keep_per_species,
213+
keep_recent_per_species=keep_recent_per_species,
214+
)
207215

208216
estimated_bytes = len(candidates) * ESTIMATED_SIZE_PER_DETECTION
209217
return estimated_bytes, len(candidates)
210218

211219

212-
def cleanup_storage(db_manager, target_percent=None, keep_per_species=None):
220+
def cleanup_storage(db_manager, target_percent=None, keep_per_species=None, keep_recent_per_species=None):
213221
"""Run storage cleanup to free disk space.
214222
215223
Deletes oldest audio and spectrogram files until disk usage drops
216-
below target_percent. For each species, keeps the top N recordings
217-
by confidence (protecting best recordings).
224+
below target_percent. For each species, protects the union of the
225+
top N by confidence and the latest N by timestamp.
218226
219227
SAFETY: Will not delete files if the target is unachievable with
220228
available BirdNET data. This prevents mass deletion when disk is
@@ -223,7 +231,8 @@ def cleanup_storage(db_manager, target_percent=None, keep_per_species=None):
223231
Args:
224232
db_manager: DatabaseManager instance
225233
target_percent: Target disk usage percentage (default from settings)
226-
keep_per_species: Recordings to keep per species (default from settings)
234+
keep_per_species: Top recordings per species by confidence (default from settings)
235+
keep_recent_per_species: Latest recordings per species (default from settings)
227236
228237
Returns:
229238
dict with files_deleted, bytes_freed, target_achievable, etc.
@@ -233,6 +242,8 @@ def cleanup_storage(db_manager, target_percent=None, keep_per_species=None):
233242
target_percent = config['target_percent']
234243
if keep_per_species is None:
235244
keep_per_species = config['keep_per_species']
245+
if keep_recent_per_species is None:
246+
keep_recent_per_species = config['keep_recent_per_species']
236247

237248
result = {
238249
'files_deleted': 0,
@@ -257,8 +268,10 @@ def cleanup_storage(db_manager, target_percent=None, keep_per_species=None):
257268
# Calculate how much we need to free
258269
bytes_to_free = usage['used_bytes'] - (usage['total_bytes'] * target_percent / 100)
259270

260-
# Fetch cleanup candidates once (oldest first, beyond top N per species)
261-
candidates = db_manager.get_cleanup_candidates(keep_per_species=keep_per_species)
271+
candidates = db_manager.get_cleanup_candidates(
272+
keep_per_species=keep_per_species,
273+
keep_recent_per_species=keep_recent_per_species,
274+
)
262275
candidate_count = len(candidates)
263276

264277
# SAFETY CHECK: Estimate if we can actually reach the target
@@ -281,12 +294,14 @@ def cleanup_storage(db_manager, target_percent=None, keep_per_species=None):
281294
'target_percent': target_percent,
282295
'bytes_to_free_gb': round(bytes_to_free / (1024**3), 2),
283296
'candidate_count': candidate_count,
284-
'keep_per_species': keep_per_species
297+
'keep_per_species': keep_per_species,
298+
'keep_recent_per_species': keep_recent_per_species
285299
})
286300

287301
if not candidates:
288-
logger.info("No cleanup candidates found - all recordings within keep limit", extra={
289-
'keep_per_species': keep_per_species
302+
logger.info("No cleanup candidates found - all recordings within keep limits", extra={
303+
'keep_per_species': keep_per_species,
304+
'keep_recent_per_species': keep_recent_per_species
290305
})
291306
return result
292307

@@ -369,7 +384,8 @@ def storage_monitor_loop(stop_flag, db_manager):
369384
cleanup_storage(
370385
db_manager,
371386
target_percent=config['target_percent'],
372-
keep_per_species=config['keep_per_species']
387+
keep_per_species=config['keep_per_species'],
388+
keep_recent_per_species=config['keep_recent_per_species']
373389
)
374390

375391
except Exception as e:

0 commit comments

Comments
 (0)