Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 13 additions & 9 deletions src/changedetection_tui/dashboard/buttons.py
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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)
Expand All @@ -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"':
Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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
8 changes: 5 additions & 3 deletions src/changedetection_tui/dashboard/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
8 changes: 4 additions & 4 deletions src/changedetection_tui/dashboard/diff_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/changedetection_tui/dashboard/header.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions src/changedetection_tui/dashboard/watchlist.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
70 changes: 68 additions & 2 deletions src/changedetection_tui/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -94,16 +142,27 @@ 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] == "$"
else settings.api_key
)
async with httpx.AsyncClient() as client:
request = httpx.Request(
url=settings.url + url,
url=settings.url + route,
method=method,
params=params,
data=data,
Expand All @@ -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


Expand Down
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.