diff --git a/README.md b/README.md index b082f5e..54d4bc5 100644 --- a/README.md +++ b/README.md @@ -142,8 +142,8 @@ Current keybindings can be seen in the footer, they can be remapped in settings. - [x] implement compact view mode - [x] improve docker documentation usage -- [ ] create video demo -- [ ] custom diff views +- [ ] create video demo? +- [ ] custom diff views? ## 👨‍💻 Development diff --git a/pyproject.toml b/pyproject.toml index 88851c3..07acd6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ authors = [ ] requires-python = ">=3.10" dependencies = [ + "cachetools>=5.0.0", "click>=8.2.1", "httpx>=0.28.1", "icdiff>=2.0.7", diff --git a/src/changedetection_tui/dashboard/buttons.py b/src/changedetection_tui/dashboard/buttons.py index 64fd211..9fe7345 100644 --- a/src/changedetection_tui/dashboard/buttons.py +++ b/src/changedetection_tui/dashboard/buttons.py @@ -1,10 +1,10 @@ +from typing import final import httpx from textual import on from changedetection_tui.dashboard.diff_widgets import DiffPanelScreen from changedetection_tui.types import ApiListWatch from textual.widgets import Button from textual.message import Message - from changedetection_tui.utils import make_api_request assigned_jump_keys: set[str] = set() @@ -18,6 +18,7 @@ def _get_next_jump_key() -> str | None: return None +@final class RecheckButton(Button): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -27,7 +28,7 @@ def __init__(self, *args, **kwargs) -> None: async def action_recheck(self, uuid: str) -> None: res = await make_api_request( app=self.app, - url=f"/api/v1/watch/{uuid}", + route=f"/api/v1/watch/{uuid}", params={"recheck": "true"}, ) if res.text.rstrip("\n") != '"OK"': @@ -36,12 +37,12 @@ async def action_recheck(self, uuid: str) -> None: request=res.request, response=res, ) - res = await make_api_request(self.app, url=f"/api/v1/watch/{uuid}") + res = await make_api_request(self.app, route=f"/api/v1/watch/{uuid}") # ATM this actually returns a larger watch obj compared to the smaller # one returned by the list watches api, but that is a subset so it # still works. watch = ApiListWatch.model_validate(res.json()) - self.post_message(UpdatedWatchEvent(watch)) + _ = self.post_message(UpdatedWatchEvent(watch, uuid)) class DiffButton(Button): @@ -54,6 +55,7 @@ async def action_execute_diff(self, uuid: str) -> None: self.app.push_screen(DiffPanelScreen(uuid=uuid)) +@final class SwitchViewedStateButton(Button): def __init__( self, *args, uuid: str, last_changed: int, viewed: bool, **kwargs @@ -67,24 +69,26 @@ def __init__( @on(Button.Pressed) async def switch_watch_viewed_state(self, event: Button.Pressed) -> None: - event.stop() + _ = event.stop() # add + or - 1 to the last_checked ts based on its viewed state. last_viewed_ts = self.last_changed + (-1 if self.viewed else +1) res = await make_api_request( self.app, - url=f"/api/v1/watch/{self.uuid}", + route=f"/api/v1/watch/{self.uuid}", json={"last_viewed": last_viewed_ts}, method="PUT", ) - res = await make_api_request(self.app, url=f"/api/v1/watch/{self.uuid}") + res = await make_api_request(self.app, route=f"/api/v1/watch/{self.uuid}") # ATM this actually returns a larger watch obj compared to the smaller # one returned by the list watches api, but that is a subset so it # still works. watch = ApiListWatch.model_validate(res.json()) - self.post_message(UpdatedWatchEvent(watch)) + _ = self.post_message(UpdatedWatchEvent(watch, self.uuid)) +@final class UpdatedWatchEvent(Message): - def __init__(self, watch: ApiListWatch) -> None: + def __init__(self, watch: ApiListWatch, uuid: str) -> None: super().__init__() self.watch = watch + self.uuid = uuid diff --git a/src/changedetection_tui/dashboard/dashboard.py b/src/changedetection_tui/dashboard/dashboard.py index 8602a7b..4deae70 100644 --- a/src/changedetection_tui/dashboard/dashboard.py +++ b/src/changedetection_tui/dashboard/dashboard.py @@ -102,16 +102,18 @@ async def search_watches( ) -> ApiListWatches: params = {} if search_term: - url = "/api/v1/search" + route = "/api/v1/search" params = {"q": search_term, "partial": "true"} if tag_title: params["tag"] = tag_title else: params = {"tag": tag_title} if tag_title else None - url = "/api/v1/watch" + route = "/api/v1/watch" try: - response = (await make_api_request(self.app, url=url, params=params)).json() + response = ( + await make_api_request(self.app, route=route, params=params) + ).json() except httpx.HTTPError as exc: r = exc.request message = f"Error connecting to {r.url}" diff --git a/src/changedetection_tui/dashboard/diff_widgets.py b/src/changedetection_tui/dashboard/diff_widgets.py index 949e4cf..08fef7c 100644 --- a/src/changedetection_tui/dashboard/diff_widgets.py +++ b/src/changedetection_tui/dashboard/diff_widgets.py @@ -69,12 +69,12 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: return from_data = ( await make_api_request( - self.app, url=f"/api/v1/watch/{self.uuid}/history/{from_ts}" + self.app, route=f"/api/v1/watch/{self.uuid}/history/{from_ts}" ) ).text to_data = ( await make_api_request( - self.app, url=f"/api/v1/watch/{self.uuid}/history/{to_ts}" + self.app, route=f"/api/v1/watch/{self.uuid}/history/{to_ts}" ) ).text with TemporaryDirectory() as tmpdir: @@ -103,7 +103,7 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: @work(exclusive=True) async def load_data(self, uuid: str) -> tuple[list[int], int, ApiWatch]: - res = await make_api_request(self.app, url=f"/api/v1/watch/{uuid}/history") + res = await make_api_request(self.app, route=f"/api/v1/watch/{uuid}/history") json = cast(dict[str, str], res.json()) snapshot_timestamps = [ int(x) @@ -113,7 +113,7 @@ async def load_data(self, uuid: str) -> tuple[list[int], int, ApiWatch]: reverse=True, ) ] - res = await make_api_request(self.app, url=f"/api/v1/watch/{uuid}") + res = await make_api_request(self.app, route=f"/api/v1/watch/{uuid}") watch = ApiWatch.model_validate(res.json()) best_from_ts = get_best_snapshot_ts_based_on_last_viewed( snapshot_timestamps=snapshot_timestamps, diff --git a/src/changedetection_tui/dashboard/header.py b/src/changedetection_tui/dashboard/header.py index a11bb32..ef6aff5 100644 --- a/src/changedetection_tui/dashboard/header.py +++ b/src/changedetection_tui/dashboard/header.py @@ -176,7 +176,7 @@ async def on_mount(self) -> None: # exit_on_error=False to be able to catch exception in caller. @work(exclusive=True, exit_on_error=False) async def load_tags(self) -> ApiListTags: - res = await make_api_request(self.app, url="/api/v1/tags") + res = await make_api_request(self.app, route="/api/v1/tags") tags = ApiListTags.model_validate(res.json()) return tags diff --git a/src/changedetection_tui/dashboard/watchlist.py b/src/changedetection_tui/dashboard/watchlist.py index 873cf03..583c040 100644 --- a/src/changedetection_tui/dashboard/watchlist.py +++ b/src/changedetection_tui/dashboard/watchlist.py @@ -1,4 +1,6 @@ from itertools import islice +from textual import on +from changedetection_tui.utils import invalidate_watchlist_cache try: from itertools import batched @@ -173,3 +175,10 @@ def action_go_up_or_down( if predicate(sibling.virtual_region.y, parent_watchrow.virtual_region.y): sibling.focus_row(at_virtual_x=self.screen.focused.virtual_region.x) break + + @on(buttons.UpdatedWatchEvent) + def update_all_rows(self, event: buttons.UpdatedWatchEvent) -> None: + """Takes care of updating the single watch in the list of rows""" + # Invalidate watchlist cache after recheck + invalidate_watchlist_cache() + self.all_rows.root[event.uuid] = event.watch diff --git a/src/changedetection_tui/utils.py b/src/changedetection_tui/utils.py index 51af93c..27b5ac7 100644 --- a/src/changedetection_tui/utils.py +++ b/src/changedetection_tui/utils.py @@ -5,10 +5,58 @@ from collections.abc import Mapping from textual.app import App from textual.binding import Binding +from cachetools import TTLCache from changedetection_tui.settings import SETTINGS, Settings, default_keymap import functools import operator +# Cache configuration +_watchlist_cache = TTLCache[str, httpx.Response](maxsize=100, ttl=600) # 10 minutes +_history_cache = TTLCache[str, httpx.Response](maxsize=100, ttl=1200) # 20 minutes +_tags_cache = TTLCache[str, httpx.Response](maxsize=100, ttl=2400) # 40 minutes + + +def _get_cache_key( + route: str, method: str, params: dict[str, str] | None = None +) -> str: + """Generate cache key based on hostname, route, method, and parameters.""" + settings = SETTINGS.get() + cache_key = f"{method}:{settings.url}:{route}" + if params: + cache_key += f":{hash(frozenset(params.items()))}" + return cache_key + + +def _get_ttl_cache( + route: str, method: str, params: dict[str, str] | None +) -> TTLCache[str, httpx.Response] | None: + """Determine if response should be cached and return appropriate cache.""" + if method != "GET": + return None + # A GET api with side effects, yeah, I know... + if params and params.get("recheck", None): + return None + + # Watch list and search endpoints (but not individual watch details) + if route == "/api/v1/watch" or route == "/api/v1/search": + return _watchlist_cache + + # Tags endpoint + if route == "/api/v1/tags": + return _tags_cache + + # Watch history endpoints + if route.endswith("/history"): + return _history_cache + + # Individual watch details and other endpoints - no caching + return None + + +def invalidate_watchlist_cache() -> None: + """Clear watchlist cache after write operations.""" + _watchlist_cache.clear() + def format_timestamp( timestamp: int, format_type: Literal["relative", "absolute", "both"] = "both" @@ -84,7 +132,7 @@ def get_best_snapshot_ts_based_on_last_viewed( async def make_api_request( app: App[None], - url: str, + route: str, method: str = "GET", params: dict[str, str] | None = None, data: Mapping[str, Any] | None = None, @@ -94,8 +142,19 @@ async def make_api_request( from changedetection_tui.app import TuiApp app = cast(TuiApp, app) + settings = SETTINGS.get() + cache = _get_ttl_cache(route, method, params) + # Just: "if cache"" is not enough, the obj behaves like a {} at the beginning. + if cache is not None: + # Check cache first for GET requests + cache_key = _get_cache_key(route, method, params) + app.log.info(f"{cache_key}") + if cache_key in cache: + app.log.info(f"Serving from cache key {cache_key}") + return cache[cache_key] + api_key = ( (os.getenv(settings.api_key[1:]) or "") if settings.api_key[0] == "$" @@ -103,7 +162,7 @@ async def make_api_request( ) async with httpx.AsyncClient() as client: request = httpx.Request( - url=settings.url + url, + url=settings.url + route, method=method, params=params, data=data, @@ -129,6 +188,13 @@ async def make_api_request( severity="error", ) raise + + # Store response in cache if applicable + if cache is not None: + cache_key = _get_cache_key(route, method, params) + app.log.info(f"Storing to cache key {cache_key}") + cache[cache_key] = res + return res diff --git a/uv.lock b/uv.lock index 5082c92..013b7b0 100644 --- a/uv.lock +++ b/uv.lock @@ -203,6 +203,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/d5/17d24fd7ba9d899b82859ee04f4599a1e8a02a85c0753bc15eb3ca7ffff7/basedpyright-1.32.1-py3-none-any.whl", hash = "sha256:06b5cc56693e3690653955e19fbe5d2e38f2a343563b40ef95fd1b10fa556fb6", size = 11841548, upload-time = "2025-10-23T12:53:25.541Z" }, ] +[[package]] +name = "cachetools" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" }, +] + [[package]] name = "certifi" version = "2025.8.3" @@ -226,6 +235,7 @@ name = "changedetection-tui" version = "1.0.0" source = { editable = "." } dependencies = [ + { name = "cachetools" }, { name = "click" }, { name = "httpx" }, { name = "icdiff" }, @@ -252,6 +262,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cachetools", specifier = ">=5.0.0" }, { name = "click", specifier = ">=8.2.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "icdiff", specifier = ">=2.0.7" },