Skip to content

Commit 3b763b9

Browse files
author
valentinfrlch
committed
Fix timeline retention
1 parent 52b2330 commit 3b763b9

File tree

2 files changed

+199
-7
lines changed

2 files changed

+199
-7
lines changed

custom_components/llmvision/timeline.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,69 @@ def _ensure_datetime(self, dt):
496496
dt = dt.replace(tzinfo=datetime.timezone.utc)
497497
return dt
498498

499+
def _get_retention_days(self) -> int | None:
500+
"""Return the configured retention window in days (None disables cleanup)."""
501+
raw_value = self._config_entry.options.get(CONF_RETENTION_TIME)
502+
if raw_value is None:
503+
raw_value = self.retention_time
504+
505+
if raw_value in (None, ""):
506+
return None
507+
508+
try:
509+
days = int(raw_value)
510+
except (TypeError, ValueError):
511+
_LOGGER.warning(
512+
"Invalid retention_time=%s. Skipping automatic event purge.",
513+
raw_value,
514+
)
515+
return None
516+
517+
if days <= 0:
518+
return None
519+
520+
return days
521+
522+
async def _purge_expired_events(self) -> None:
523+
"""Remove events older than now - retention_time days."""
524+
if getattr(self, "_migrating", False):
525+
return
526+
527+
retention_days = self._get_retention_days()
528+
if retention_days is None:
529+
return
530+
531+
cutoff = dt_util.utcnow() - datetime.timedelta(days=retention_days)
532+
cutoff_local = dt_util.as_local(self._ensure_datetime(cutoff)).isoformat()
533+
534+
try:
535+
async with aiosqlite.connect(self._db_path) as db:
536+
async with db.execute(
537+
"""
538+
SELECT uid, key_frame FROM events
539+
WHERE start IS NOT NULL AND start < ?
540+
""",
541+
(cutoff_local,),
542+
) as cursor:
543+
stale_rows = list(await cursor.fetchall())
544+
545+
if not stale_rows:
546+
return
547+
548+
await db.executemany(
549+
"DELETE FROM events WHERE uid = ?",
550+
[(row[0],) for row in stale_rows],
551+
)
552+
await db.commit()
553+
554+
_LOGGER.info(
555+
"Purged %s expired timeline event(s) older than %s day(s)",
556+
len(stale_rows),
557+
retention_days,
558+
)
559+
except aiosqlite.Error as e:
560+
_LOGGER.error(f"Error purging expired timeline events: {e}")
561+
499562
async def _get_category_from_label(self, label: str) -> str:
500563
"""Returns the category for a given label using the language regex template."""
501564
return (await _get_category_and_label(self.hass, self._config_entry, label))[0]
@@ -511,6 +574,7 @@ async def get_linked_images(self):
511574

512575
async def load_events(self):
513576
"""Loads events from the database into memory"""
577+
await self._purge_expired_events()
514578
self.events = []
515579
try:
516580
async with aiosqlite.connect(self._db_path) as db:
@@ -585,6 +649,7 @@ async def get_events_json(
585649
Supports filtering by camera, start/end range and sorting by start (newest first).
586650
category are ignored for now.
587651
"""
652+
await self._purge_expired_events()
588653
events: list[dict] = []
589654

590655
# Normalize start/end inputs to timezone-aware datetimes (or None)

tests/test_timeline.py

Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
"""Unit tests for timeline.py module."""
2-
import pytest
3-
from unittest.mock import Mock, patch, AsyncMock, MagicMock
2+
43
import datetime
4+
import os
5+
import uuid
56
from dataclasses import dataclass
7+
from unittest.mock import AsyncMock, Mock
8+
9+
import aiosqlite
10+
import pytest
11+
12+
from custom_components.llmvision.timeline import Timeline
13+
from homeassistant.util import dt as dt_util
614

715

816
@dataclass
@@ -30,19 +38,58 @@ def mock_hass(self):
3038
hass.config.path = Mock(return_value="/mock/path")
3139
hass.loop = Mock()
3240
hass.loop.run_in_executor = AsyncMock()
41+
hass.async_add_executor_job = AsyncMock()
42+
hass.async_create_task = lambda coro: None
3343
return hass
3444

3545
@pytest.fixture
3646
def mock_config_entry(self):
3747
"""Create a mock config entry."""
3848
entry = Mock()
3949
entry.entry_id = "test_entry"
40-
entry.data = {
41-
"provider": "Settings",
42-
"retention_time": 7
43-
}
50+
entry.data = {"provider": "Settings", "retention_time": 7}
51+
entry.options = {}
4452
return entry
4553

54+
@pytest.fixture
55+
def timeline_factory(self, tmp_path, monkeypatch):
56+
"""Factory that builds isolated Timeline instances for retention tests."""
57+
58+
base_path = tmp_path / "config"
59+
base_path.mkdir()
60+
61+
real_makedirs = os.makedirs
62+
63+
def safe_makedirs(path, exist_ok=False):
64+
if str(path).startswith("/media/llmvision"):
65+
return
66+
return real_makedirs(path, exist_ok=exist_ok)
67+
68+
monkeypatch.setattr(
69+
"custom_components.llmvision.timeline.os.makedirs", safe_makedirs
70+
)
71+
72+
def _build(retention=2, options=None):
73+
hass = Mock()
74+
hass.data = {}
75+
hass.config = Mock()
76+
hass.config.path = lambda *parts: str(base_path.joinpath(*parts))
77+
hass.loop = Mock()
78+
hass.loop.run_in_executor = AsyncMock()
79+
hass.async_add_executor_job = AsyncMock()
80+
hass.async_create_task = lambda coro: None
81+
82+
entry = Mock()
83+
entry.entry_id = "timeline-entry"
84+
entry.data = {"provider": "Settings", "retention_time": retention}
85+
entry.options = options or {}
86+
87+
timeline = Timeline(hass, entry)
88+
timeline._migrating = False
89+
return timeline
90+
91+
return _build
92+
4693
def test_retention_time_from_config(self, mock_hass, mock_config_entry):
4794
"""Test retention time is read from config."""
4895
assert mock_config_entry.data["retention_time"] == 7
@@ -56,7 +103,87 @@ def test_mock_event_creation(self):
56103
end=datetime.datetime.now(),
57104
description="Test desc"
58105
)
59-
106+
60107
assert event.uid == "test-uid"
61108
assert event.title == "Test"
62109
assert event.key_frame == ""
110+
111+
async def _insert_rows(
112+
self,
113+
db_path: str,
114+
rows: list[tuple[str, str, str, str, str, str, str, str, str]],
115+
):
116+
async with aiosqlite.connect(db_path) as db:
117+
for row in rows:
118+
await db.execute(
119+
"""
120+
INSERT INTO events (
121+
uid, title, start, end, description,
122+
key_frame, camera_name, category, label
123+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
124+
""",
125+
row,
126+
)
127+
await db.commit()
128+
129+
def _build_row(
130+
self, title: str, age_days: float
131+
) -> tuple[str, str, str, str, str, str, str, str, str]:
132+
base = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
133+
days=age_days
134+
)
135+
start = dt_util.as_local(base).isoformat()
136+
end = dt_util.as_local(base + datetime.timedelta(minutes=1)).isoformat()
137+
return (
138+
str(uuid.uuid4()),
139+
title,
140+
start,
141+
end,
142+
"",
143+
"",
144+
"",
145+
"",
146+
"",
147+
)
148+
149+
async def _fetch_titles(self, db_path: str) -> list[str]:
150+
async with aiosqlite.connect(db_path) as db:
151+
async with db.execute("SELECT title FROM events ORDER BY title") as cursor:
152+
rows = await cursor.fetchall()
153+
return [row[0] for row in rows]
154+
155+
@pytest.mark.asyncio
156+
async def test_retention_purges_old_events(self, timeline_factory):
157+
"""Events older than retention_time days are purged when loading."""
158+
159+
timeline = timeline_factory(retention=2)
160+
await timeline._initialize_db()
161+
162+
rows = [
163+
self._build_row("expired", age_days=5),
164+
self._build_row("recent", age_days=0.1),
165+
]
166+
await self._insert_rows(timeline._db_path, rows)
167+
168+
await timeline.load_events()
169+
170+
titles = await self._fetch_titles(timeline._db_path)
171+
assert titles == ["recent"]
172+
173+
@pytest.mark.asyncio
174+
async def test_zero_retention_disables_purge(self, timeline_factory):
175+
"""Setting retention_time to 0 leaves old events untouched."""
176+
177+
timeline = timeline_factory(retention=0)
178+
await timeline._initialize_db()
179+
180+
rows = [
181+
self._build_row("expired", age_days=10),
182+
self._build_row("recent", age_days=0.5),
183+
]
184+
await self._insert_rows(timeline._db_path, rows)
185+
186+
await timeline.load_events()
187+
188+
titles = await self._fetch_titles(timeline._db_path)
189+
assert titles == ["expired", "recent"]

0 commit comments

Comments
 (0)