diff --git a/CHANGELOG.md b/CHANGELOG.md index 493640b..ced1f11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 1.5.0 + +### Added + +- **Redis search cache** — identical searches within a configurable time window are served from Redis cache instead of re-querying upstream engines + - Cache TTL configurable via UI (Settings → Cache tab) from 0 hours (disabled) to 1 week + - Default TTL: 6 hours + - TTL setting persisted in SQLite + - Cache key is deterministic hash of query + category + page + image_size + engines + - Only successful results (with at least 1 result) are cached +- **Cache management UI** — new "Cache" tab in Settings modal + - Redis connection status indicator (green dot = connected) + - Preset buttons: Disabled, 1h, 6h, 12h, 24h, 3 days, 1 week + - Continuous slider for fine-grained TTL control + - "Clear all cached results" flush button +- **Settings API** — `GET/PUT /api/settings` for cache_ttl_hours, `DELETE /api/cache` for flushing +- Redis is optional — when `REDIS_URL` is not set or Redis is unreachable, caching is silently disabled + ## 1.4.0 ### Added diff --git a/Dockerfile b/Dockerfile index 819f478..34254ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,10 @@ COPY backend/ . # Copy frontend build output to static dir COPY --from=frontend-build /app/frontend/dist /app/static +# Redis is optional — set REDIS_URL to enable caching +# e.g. docker run -e REDIS_URL=redis://redis:6379 ... +ENV REDIS_URL="" + EXPOSE 8000 CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/FEATURES.md b/FEATURES.md index 754013c..a868a82 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -36,6 +36,14 @@ - **Error reporting** — when an upstream engine fails or times out, the UI shows a toast notification identifying which engine had issues - **Graceful degradation** — partial failures don't block results from other engines +## Caching + +- **Redis cache** — identical searches served from Redis cache to reduce upstream load and latency +- **Configurable TTL** — cache duration adjustable from 0 (disabled) to 168 hours (1 week); default 6 hours +- **UI controls** — preset buttons + slider in Settings → Cache tab, with flush button +- **Optional** — when `REDIS_URL` is not set or Redis is unreachable, caching is silently disabled +- **Deterministic keys** — cache key derived from query + category + page + image_size + engines + ## UI / UX - **Mobile-first** responsive design built with React, Tailwind CSS, and shadcn theming diff --git a/README.md b/README.md index 17039a0..f278d54 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Inspired by [SearXNG](https://github.com/searxng/searxng). - [uv](https://docs.astral.sh/uv/) (Python package manager) - [Node.js](https://nodejs.org/) >= 18 - Python >= 3.12 +- [Redis](https://redis.io/) (optional — enables search result caching) ## Running Locally @@ -26,6 +27,12 @@ uv sync # install dependencies uv run uvicorn app.main:app --reload --port 8000 ``` +To enable Redis caching: + +```bash +REDIS_URL=redis://192.168.1.2:6399 uv run uvicorn app.main:app --reload --port 8000 +``` + The API will be available at `http://localhost:8000`. Interactive docs: - Swagger UI: http://localhost:8000/docs @@ -41,11 +48,26 @@ npm run dev # start dev server with hot reload The frontend dev server runs at `http://localhost:5173` and proxies `/api` requests to the backend. -### 3. Docker (production) +### 3. Quick start (both) + +```bash +# Without Redis (caching disabled) +just dev + +# With Redis +REDIS_URL=redis://192.168.1.2:6399 just dev +``` + +### 4. Docker (production) ```bash docker build -t hey-search . + +# Without Redis docker run -p 8000:8000 hey-search + +# With Redis +docker run -p 8000:8000 -e REDIS_URL=redis://your-redis:6379 hey-search ``` Then open http://localhost:8000. @@ -58,6 +80,9 @@ Then open http://localhost:8000. | GET | `/api/autocomplete?q=` | Autocomplete suggestions | | GET | `/api/engines` | List all search engines | | PUT | `/api/engines/{name}` | Enable/disable an engine | +| GET | `/api/settings` | Get app settings (cache TTL)| +| PUT | `/api/settings` | Update settings | +| DELETE | `/api/cache` | Flush search cache | ## Features diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 59458ae..6c66e06 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -5,12 +5,14 @@ from __future__ import annotations from typing import Literal from fastapi import APIRouter, Query, Request from fastapi.responses import JSONResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from app.models import SearchResponse, EngineInfo, APIError from app.search import search, get_autocomplete from app.engines import registry from app.excluded import get_excluded_domains, add_excluded_domain, remove_excluded_domain +from app.settings import get_all_settings, get_setting, set_setting +from app.cache import is_cache_available, flush_cache router = APIRouter() @@ -227,3 +229,83 @@ async def api_remove_excluded_domain(domain: str): content=APIError(code="not_found", message=f"Domain '{domain}' not in exclusion list").model_dump(), ) return ExcludedDomainsResponse(domains=get_excluded_domains()) + + +# --- Settings --- + +class SettingsResponse(BaseModel): + cache_ttl_hours: float = Field(description="Cache TTL in hours (0 = disabled, max 168 = 1 week)") + cache_available: bool = Field(description="Whether Redis is connected and available") + + +class UpdateSettingsRequest(BaseModel): + cache_ttl_hours: float = Field(ge=0, le=168, description="Cache TTL in hours (0 = disabled, max 168 = 1 week)") + + +@router.get( + "/settings", + response_model=SettingsResponse, + summary="Get application settings", + description="""Returns current application settings including cache TTL. + +**Example:** +```bash +curl '$BASE_URL/api/settings' +``` +""", + tags=["Settings"], +) +async def api_get_settings(): + settings = get_all_settings() + return SettingsResponse( + cache_ttl_hours=float(settings.get("cache_ttl_hours", "6")), + cache_available=is_cache_available(), + ) + + +@router.put( + "/settings", + response_model=SettingsResponse, + summary="Update application settings", + description="""Update settings such as cache TTL. Set `cache_ttl_hours` to 0 to disable caching. + +**Example:** +```bash +curl -X PUT '$BASE_URL/api/settings' \\ + -H 'Content-Type: application/json' \\ + -d '{"cache_ttl_hours": 12}' +``` +""", + tags=["Settings"], +) +async def api_update_settings(body: UpdateSettingsRequest): + set_setting("cache_ttl_hours", str(body.cache_ttl_hours)) + return SettingsResponse( + cache_ttl_hours=body.cache_ttl_hours, + cache_available=is_cache_available(), + ) + + +# --- Cache Management --- + +class CacheFlushResponse(BaseModel): + keys_deleted: int + message: str + + +@router.delete( + "/cache", + response_model=CacheFlushResponse, + summary="Flush the search cache", + description="""Delete all cached search results from Redis. + +**Example:** +```bash +curl -X DELETE '$BASE_URL/api/cache' +``` +""", + tags=["Settings"], +) +async def api_flush_cache(): + count = await flush_cache() + return CacheFlushResponse(keys_deleted=count, message=f"Deleted {count} cached entries") diff --git a/backend/app/cache.py b/backend/app/cache.py new file mode 100644 index 0000000..720cf3c --- /dev/null +++ b/backend/app/cache.py @@ -0,0 +1,107 @@ +"""Optional Redis cache for search results. + +When REDIS_URL is not set or Redis is unreachable, all operations +gracefully degrade to no-ops (cache miss / skip). +""" + +from __future__ import annotations + +import json +import hashlib +import logging +import os + +import redis.asyncio as aioredis + +from app.settings import get_cache_ttl_seconds + +logger = logging.getLogger(__name__) + +_redis: aioredis.Redis | None = None +_available: bool = False + + +async def init_redis() -> None: + """Try to connect to Redis. If it fails, caching is silently disabled.""" + global _redis, _available + url = os.environ.get("REDIS_URL", "") + if not url: + logger.info("REDIS_URL not set — caching disabled") + return + try: + _redis = aioredis.from_url(url, decode_responses=True, socket_connect_timeout=3) + await _redis.ping() + _available = True + logger.info("Redis connected at %s — caching enabled", url) + except Exception as e: + logger.warning("Redis unavailable (%s) — caching disabled", e) + _redis = None + _available = False + + +async def close_redis() -> None: + global _redis, _available + if _redis: + await _redis.aclose() + _redis = None + _available = False + + +def is_cache_available() -> bool: + return _available and _redis is not None + + +def _cache_key(query: str, category: str, page: int, image_size: str, engines: str) -> str: + """Build a deterministic cache key.""" + raw = f"hs:{category}:{page}:{image_size}:{engines}:{query}" + h = hashlib.sha256(raw.encode()).hexdigest()[:16] + return f"hs:search:{h}" + + +async def get_cached(query: str, category: str, page: int, image_size: str, engines: str) -> dict | None: + """Return cached search response dict, or None on miss.""" + if not is_cache_available(): + return None + ttl = get_cache_ttl_seconds() + if ttl <= 0: + return None + key = _cache_key(query, category, page, image_size, engines) + try: + data = await _redis.get(key) # type: ignore[union-attr] + if data: + logger.debug("Cache HIT for key %s", key) + return json.loads(data) + except Exception as e: + logger.warning("Cache get error: %s", e) + return None + + +async def set_cached(query: str, category: str, page: int, image_size: str, engines: str, response_dict: dict) -> None: + """Store a search response in cache.""" + if not is_cache_available(): + return + ttl = get_cache_ttl_seconds() + if ttl <= 0: + return + key = _cache_key(query, category, page, image_size, engines) + try: + await _redis.set(key, json.dumps(response_dict, default=str), ex=ttl) # type: ignore[union-attr] + logger.debug("Cache SET for key %s (ttl=%ds)", key, ttl) + except Exception as e: + logger.warning("Cache set error: %s", e) + + +async def flush_cache() -> int: + """Delete all hey-search cache keys. Returns count deleted.""" + if not is_cache_available(): + return 0 + try: + keys = [] + async for key in _redis.scan_iter("hs:search:*"): # type: ignore[union-attr] + keys.append(key) + if keys: + await _redis.delete(*keys) # type: ignore[union-attr] + return len(keys) + except Exception as e: + logger.warning("Cache flush error: %s", e) + return 0 diff --git a/backend/app/main.py b/backend/app/main.py index fbd05a0..cfc2247 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,14 +15,19 @@ from fastapi.staticfiles import StaticFiles from app.api.routes import router from app.engines import registry from app.excluded import init_db +from app.settings import init_settings_table +from app.cache import init_redis, close_redis from app.models import APIError @asynccontextmanager async def lifespan(application: FastAPI): init_db() + init_settings_table() registry.load_default_engines() + await init_redis() yield + await close_redis() app = FastAPI( diff --git a/backend/app/search.py b/backend/app/search.py index c2f41e8..1158028 100644 --- a/backend/app/search.py +++ b/backend/app/search.py @@ -12,6 +12,7 @@ from app.models import WebResult, ImageResult, EngineError, EngineStat, SearchRe from app.engines.base import SearchEngine, SearchCategory from app.engines import registry from app.excluded import is_url_excluded +from app import cache logger = logging.getLogger(__name__) @@ -60,7 +61,15 @@ async def search( engines: list[str] | None = None, image_size: str = "", ) -> SearchResponse: - """Search across all enabled engines concurrently.""" + """Search across all enabled engines concurrently, with optional Redis caching.""" + engines_key = ",".join(sorted(engines)) if engines else "" + + # Check cache first + cached = await cache.get_cached(query, category, page, image_size, engines_key) + if cached is not None: + resp = SearchResponse(**cached) + return resp + enabled_engines = registry.get_enabled_engines() if engines: @@ -118,7 +127,7 @@ async def search( for i, r in enumerate(unique_results): r.rank = i + 1 - return SearchResponse( + response = SearchResponse( query=query, category=category, page=page, @@ -129,6 +138,12 @@ async def search( has_next=any(s.result_count > 0 for s in all_stats if s.status == "ok"), ) + # Store in cache (only if we got results) + if unique_results: + await cache.set_cached(query, category, page, image_size, engines_key, response.model_dump()) + + return response + async def get_autocomplete(query: str) -> list[str]: """Get autocomplete suggestions from enabled engines.""" diff --git a/backend/app/settings.py b/backend/app/settings.py new file mode 100644 index 0000000..b1ca2b4 --- /dev/null +++ b/backend/app/settings.py @@ -0,0 +1,76 @@ +"""SQLite-backed application settings.""" + +from __future__ import annotations + +import logging + +from app.excluded import _get_conn + +logger = logging.getLogger(__name__) + +# Default values +DEFAULTS: dict[str, str] = { + "cache_ttl_hours": "6", +} + + +def init_settings_table() -> None: + """Create the settings table and populate defaults.""" + conn = _get_conn() + conn.execute( + """ + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """ + ) + # Insert defaults for any missing keys + for key, default in DEFAULTS.items(): + conn.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + (key, default), + ) + conn.commit() + conn.close() + logger.info("Settings table initialized") + + +def get_setting(key: str) -> str: + """Get a single setting value. Returns default if not set.""" + conn = _get_conn() + row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + conn.close() + if row: + return row[0] + return DEFAULTS.get(key, "") + + +def set_setting(key: str, value: str) -> None: + """Set a single setting value.""" + conn = _get_conn() + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + (key, value), + ) + conn.commit() + conn.close() + + +def get_all_settings() -> dict[str, str]: + """Get all settings as a dict.""" + conn = _get_conn() + rows = conn.execute("SELECT key, value FROM settings").fetchall() + conn.close() + result = dict(DEFAULTS) # start with defaults + result.update({k: v for k, v in rows}) + return result + + +def get_cache_ttl_seconds() -> int: + """Get cache TTL in seconds. Returns 0 if caching is disabled.""" + try: + hours = float(get_setting("cache_ttl_hours")) + return max(0, int(hours * 3600)) + except (ValueError, TypeError): + return 6 * 3600 # fallback: 6 hours diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d20c279..a58f489 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,4 +11,5 @@ dependencies = [ "tenacity", "lxml", "beautifulsoup4", + "redis>=5.0", ] diff --git a/backend/uv.lock b/backend/uv.lock index 1a9e6b6..9177914 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -111,6 +111,7 @@ dependencies = [ { name = "httpx" }, { name = "lxml" }, { name = "pydantic" }, + { name = "redis" }, { name = "tenacity" }, { name = "uvicorn" }, ] @@ -122,6 +123,7 @@ requires-dist = [ { name = "httpx" }, { name = "lxml" }, { name = "pydantic" }, + { name = "redis", specifier = ">=5.0" }, { name = "tenacity" }, { name = "uvicorn" }, ] @@ -329,6 +331,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] +[[package]] +name = "redis" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" }, +] + [[package]] name = "soupsieve" version = "2.8.3" diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index af06993..fcef7c7 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, type FormEvent } from "react"; -import { Settings, ToggleLeft, ToggleRight, Plus, Trash2, ExternalLink } from "lucide-react"; +import { Settings, ToggleLeft, ToggleRight, Plus, Trash2, ExternalLink, Database } from "lucide-react"; import { getEngines, toggleEngine, type EngineInfo } from "@/lib/api"; import { getExcludedDomains, addExcludedDomain, removeExcludedDomain } from "@/lib/api"; +import { getSettings, updateSettings, flushCache, type AppSettings } from "@/lib/api"; import { cn } from "@/lib/utils"; -type Tab = "engines" | "excluded"; +type Tab = "engines" | "excluded" | "cache"; interface SettingsModalProps { open: boolean; @@ -44,6 +45,7 @@ export function SettingsModal({ open, onClose, initialTab = "engines" }: Setting {([ { key: "engines" as const, label: "Engines" }, { key: "excluded" as const, label: "Excluded Sites" }, + { key: "cache" as const, label: "Cache" }, ]).map(({ key, label }) => ( + ))} + + {/* Custom slider */} +
+ setTtl(Number(e.target.value))} + onMouseUp={() => handleSave(ttl)} + onTouchEnd={() => handleSave(ttl)} + className="flex-1 accent-primary disabled:opacity-40" + aria-label="Cache TTL hours" + /> + + {ttl === 0 ? "Off" : ttl < 24 ? `${ttl}h` : `${(ttl / 24).toFixed(1)}d`} + +
+ + + {/* Flush button */} +
+ + {flushMsg &&

{flushMsg}

} +
+ + ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0ba9a5e..04d6e92 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -139,3 +139,32 @@ export async function removeExcludedDomain(domain: string): Promise { const data: ExcludedDomainsResponse = await resp.json(); return data.domains; } + +// --- Settings --- + +export interface AppSettings { + cache_ttl_hours: number; + cache_available: boolean; +} + +export async function getSettings(): Promise { + const resp = await fetch(`${API_BASE}/settings`); + if (!resp.ok) throw new Error("Failed to fetch settings"); + return resp.json(); +} + +export async function updateSettings(settings: { cache_ttl_hours: number }): Promise { + const resp = await fetch(`${API_BASE}/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }); + if (!resp.ok) throw new Error("Failed to update settings"); + return resp.json(); +} + +export async function flushCache(): Promise<{ keys_deleted: number; message: string }> { + const resp = await fetch(`${API_BASE}/cache`, { method: "DELETE" }); + if (!resp.ok) throw new Error("Failed to flush cache"); + return resp.json(); +} diff --git a/justfile b/justfile index 7a4a590..62b920d 100644 --- a/justfile +++ b/justfile @@ -24,6 +24,7 @@ dev: #!/usr/bin/env bash set -e trap 'echo "Shutting down..."; kill 0; wait' INT TERM + export REDIS_URL="${REDIS_URL:-}" cd backend && uv run uvicorn app.main:app --reload --port 8000 & cd frontend && npm run dev & wait