diff --git a/api/routes/compression.py b/api/routes/compression.py index 73ef036..90a2c41 100644 --- a/api/routes/compression.py +++ b/api/routes/compression.py @@ -10,6 +10,10 @@ CompressionStartResponse, CompressionStats, CompressionStatus, + ForceRecompressPreviewRequest, + ForceRecompressPreviewResponse, + ForceRecompressRequest, + ForceRecompressResponse, ) from core.compression import CompressionProgress, compression_service from core.config import config @@ -119,3 +123,79 @@ async def get_compression_stats(): original_size_bytes=stats["original_size_bytes"], estimated_savings_bytes=estimated_savings, ) + + +@router.post("/force-recompress/preview", response_model=ForceRecompressPreviewResponse) +async def preview_force_recompress(request: ForceRecompressPreviewRequest): + """ + Preview force recompression impact. + + Shows how many screenshots would be affected by force recompression, + including how many are already compressed (which will lose quality). + """ + counts = db.get_force_recompressible_count(request.older_than_days) + + warning = "" + if counts["already_compressed"] > 0: + warning = ( + f"{counts['already_compressed']} screenshots are already compressed. " + "Re-compressing them will further reduce quality and cannot be undone." + ) + + return ForceRecompressPreviewResponse( + total_count=counts["total"], + already_compressed_count=counts["already_compressed"], + not_compressed_count=counts["not_compressed"], + warning=warning, + ) + + +@router.post("/force-recompress", response_model=ForceRecompressResponse) +async def start_force_recompress(request: ForceRecompressRequest): + """ + Start force recompression of all screenshots older than specified days. + + Unlike normal compression, this will re-compress already-compressed screenshots. + This is destructive and cannot be undone. + + Requires confirm=true to proceed. + """ + if not request.confirm: + return ForceRecompressResponse( + success=False, + message="Must set confirm=true to proceed with force recompression", + affected_count=0, + ) + + if compression_service.is_running: + return ForceRecompressResponse( + success=False, + message="Compression is already running", + affected_count=0, + ) + + counts = db.get_force_recompressible_count(request.older_than_days) + if counts["total"] == 0: + return ForceRecompressResponse( + success=True, + message=f"No screenshots older than {request.older_than_days} days found", + affected_count=0, + ) + + started = compression_service.start_force_recompress( + older_than_days=request.older_than_days, + quality=request.quality, + ) + + if not started: + return ForceRecompressResponse( + success=False, + message="Failed to start - compression may already be running", + affected_count=0, + ) + + return ForceRecompressResponse( + success=True, + message=f"Force recompression started for {counts['total']} screenshots", + affected_count=counts["total"], + ) diff --git a/api/routes/search.py b/api/routes/search.py index cf154b3..b0f00dd 100644 --- a/api/routes/search.py +++ b/api/routes/search.py @@ -58,13 +58,45 @@ async def search_screenshots(request: SearchRequest): detail="No synced screenshots. Run sync first to generate embeddings.", ) + # Validate: need either query or image + if not request.query and request.image is None: + raise HTTPException( + status_code=400, + detail="Either query or image must be provided.", + ) + # Generate embeddings based on search mode image_embedding = None text_embedding = None + # When image param is provided, use it for embedding + if request.image is not None: + if isinstance(request.image, int): + # Screenshot ID — use stored embedding + image_embedding = db.get_embedding(request.image) + if image_embedding is None: + raise HTTPException( + status_code=400, + detail="Screenshot has no embedding. Run sync first.", + ) + else: + # Base64 image data — generate embedding from bytes + import base64 + + try: + image_bytes = base64.b64decode(request.image) + except Exception as e: + raise HTTPException(status_code=400, detail="Invalid base64 image data") from e + from core.embeddings import get_image_embedding_from_bytes + + try: + image_embedding = get_image_embedding_from_bytes(image_bytes) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to process image: {e}") from e + try: - # CLIP image embedding (for image and auto modes) - if request.search_mode in (SearchMode.AUTO, SearchMode.IMAGE): + if request.image is None and request.search_mode in (SearchMode.AUTO, SearchMode.IMAGE): + # CLIP image embedding (for image and auto modes) if request.safe_mode: image_embedding = get_safe_search_embedding( text=request.query, @@ -79,8 +111,8 @@ async def search_screenshots(request: SearchRequest): else: image_embedding = get_text_embedding(request.query) - # BGE text embedding (for text_semantic and auto modes) - if request.search_mode in (SearchMode.AUTO, SearchMode.TEXT_SEMANTIC): + # BGE text embedding (for text_semantic and auto modes) - skip when using image query + if request.image is None and request.search_mode in (SearchMode.AUTO, SearchMode.TEXT_SEMANTIC): # Lazy import to avoid loading text model if not needed from core.text_embeddings import text_embedding_service @@ -167,6 +199,10 @@ async def search_screenshots(request: SearchRequest): break results = filtered_results + # Filter out the source screenshot when searching by ID + if isinstance(request.image, int): + results = [r for r in results if r["id"] != request.image] + # Convert to response search_results = [ SearchResult( diff --git a/api/schemas.py b/api/schemas.py index b5e7981..961ddc0 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -146,6 +146,37 @@ class CompressionStartResponse(BaseModel): compressible_count: int +class ForceRecompressPreviewRequest(BaseModel): + """Request to preview force recompression""" + + older_than_days: int = Field(ge=30, le=365) + + +class ForceRecompressPreviewResponse(BaseModel): + """Preview of force recompression impact""" + + total_count: int + already_compressed_count: int + not_compressed_count: int + warning: str + + +class ForceRecompressRequest(BaseModel): + """Request to start force recompression""" + + older_than_days: int = Field(ge=30, le=365) + quality: int | None = Field(None, ge=50, le=90) + confirm: bool = False + + +class ForceRecompressResponse(BaseModel): + """Response after starting force recompression""" + + success: bool + message: str + affected_count: int + + class SyncStartRequest(BaseModel): """Request to start sync""" @@ -177,7 +208,10 @@ class SearchMode(str, Enum): class SearchRequest(BaseModel): """Search request with mode selection""" - query: str = Field(..., min_length=1, max_length=500) + query: str = Field(default="", max_length=500) + image: int | str | None = Field( + default=None, description="Screenshot ID (int) or base64 image data (string) to use as image query" + ) limit: int = Field(default=20, ge=1, le=100) search_mode: SearchMode = Field( default=SearchMode.AUTO, diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 978cc46..195ed58 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -267,6 +267,199 @@ def test_get_compression_stats(self, mock_config, mock_db): assert data["compressed_count"] == 50 assert data["compressible_count"] == 20 + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_preview(self, mock_db, mock_service): + """Should return force recompress preview""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + mock_db.get_force_recompressible_count.return_value = { + "total": 100, + "already_compressed": 30, + "not_compressed": 70, + } + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress/preview", + json={"older_than_days": 60}, + ) + assert response.status_code == 200 + data = response.json() + assert data["total_count"] == 100 + assert data["already_compressed_count"] == 30 + assert data["not_compressed_count"] == 70 + + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_preview_with_warning(self, mock_db, mock_service): + """Should include warning when already-compressed screenshots exist""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + mock_db.get_force_recompressible_count.return_value = { + "total": 50, + "already_compressed": 20, + "not_compressed": 30, + } + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress/preview", + json={"older_than_days": 90}, + ) + assert response.status_code == 200 + data = response.json() + assert "20" in data["warning"] + assert "already compressed" in data["warning"] + + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_requires_confirm(self, mock_db, mock_service): + """Should reject without confirm=true""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress", + json={"older_than_days": 60, "confirm": False}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "confirm" in data["message"].lower() + + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_already_running(self, mock_db, mock_service): + """Should not start if compression already running""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + mock_service.is_running = True + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress", + json={"older_than_days": 60, "confirm": True}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "already running" in data["message"] + + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_success(self, mock_db, mock_service): + """Should start force recompression""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + mock_service.is_running = False + mock_db.get_force_recompressible_count.return_value = { + "total": 50, + "already_compressed": 10, + "not_compressed": 40, + } + mock_service.start_force_recompress.return_value = True + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress", + json={"older_than_days": 60, "confirm": True}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["affected_count"] == 50 + mock_service.start_force_recompress.assert_called_once() + + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_no_eligible(self, mock_db, mock_service): + """Should handle no eligible screenshots""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + mock_service.is_running = False + mock_db.get_force_recompressible_count.return_value = { + "total": 0, + "already_compressed": 0, + "not_compressed": 0, + } + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress", + json={"older_than_days": 365, "confirm": True}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["affected_count"] == 0 + + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_start_fails(self, mock_db, mock_service): + """Should handle TOCTOU race where start returns False""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + mock_service.is_running = False + mock_db.get_force_recompressible_count.return_value = { + "total": 10, + "already_compressed": 5, + "not_compressed": 5, + } + mock_service.start_force_recompress.return_value = False + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress", + json={"older_than_days": 60, "confirm": True}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "failed" in data["message"].lower() or "already" in data["message"].lower() + class TestSearchEndpoints: """Test search API endpoints""" @@ -329,6 +522,183 @@ def test_search_success(self, mock_get_embedding, mock_db): assert len(data["results"]) == 1 +class TestSearchByImageParam: + """Test search with image parameter (find similar via main search endpoint)""" + + @patch("api.routes.search.config") + @patch("api.routes.search.db") + def test_search_with_image_param(self, mock_db, mock_config): + """Should use stored embedding when image param is provided""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.search import router + + mock_config.similarity_metric = "cosine" + mock_db.get_stats.return_value = {"synced": 100} + mock_db.get_embedding.return_value = [0.1] * 768 + mock_db.search_similar.return_value = [ + { + "id": 1, + "image_path": "/path/to/img.jpg", + "timestamp": "251206120000", + "similarity": 1.0, + }, + { + "id": 2, + "image_path": "/path/to/img2.jpg", + "timestamp": "251206120100", + "similarity": 0.85, + }, + ] + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/search", + json={ + "image": 1, + "limit": 10, + "search_mode": "image", + }, + ) + assert response.status_code == 200 + data = response.json() + # Source screenshot (id=1) should be filtered out + assert data["total_results"] == 1 + assert data["results"][0]["id"] == 2 + + @patch("api.routes.search.db") + def test_search_with_image_no_embedding(self, mock_db): + """Should 400 if screenshot has no embedding""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.search import router + + mock_db.get_stats.return_value = {"synced": 100} + mock_db.get_embedding.return_value = None + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/search", + json={ + "image": 99999, + "limit": 10, + }, + ) + assert response.status_code == 400 + assert "no embedding" in response.json()["detail"].lower() + + @patch("api.routes.search.db") + def test_search_needs_query_or_image(self, mock_db): + """Should 400 if neither query nor image provided""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.search import router + + mock_db.get_stats.return_value = {"synced": 100} + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/search", + json={ + "limit": 10, + }, + ) + assert response.status_code == 400 + + +class TestSearchByBase64Image: + """Test search with base64 image data via POST /search""" + + @patch("api.routes.search.config") + @patch("api.routes.search.db") + @patch("core.embeddings.get_image_embedding_from_bytes") + def test_search_with_base64_image(self, mock_embed, mock_db, mock_config): + """Should generate embedding from base64 and return results""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.search import router + + mock_config.similarity_metric = "cosine" + mock_db.get_stats.return_value = {"synced": 10} + mock_embed.return_value = [0.1] * 768 + mock_db.search_similar.return_value = [ + { + "id": 1, + "image_path": "/path/to/img.jpg", + "timestamp": "251206120000", + "similarity": 0.9, + }, + ] + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + # Create a minimal valid JPEG and encode as base64 + import base64 + from io import BytesIO + + from PIL import Image + + buf = BytesIO() + Image.new("RGB", (10, 10)).save(buf, "JPEG") + image_b64 = base64.b64encode(buf.getvalue()).decode() + + response = client.post( + "/search", + json={ + "image": image_b64, + "limit": 10, + "search_mode": "image", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["total_results"] == 1 + mock_embed.assert_called_once() + + @patch("core.embeddings.get_image_embedding_from_bytes") + @patch("api.routes.search.db") + def test_search_with_bad_image_data(self, mock_db, mock_embed): + """Should 400 for base64 data that isn't a valid image""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.search import router + + mock_db.get_stats.return_value = {"synced": 10} + mock_embed.side_effect = Exception("cannot identify image file") + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + import base64 + + bad_b64 = base64.b64encode(b"not an image").decode() + response = client.post( + "/search", + json={ + "image": bad_b64, + "limit": 10, + }, + ) + assert response.status_code == 400 + + class TestRecordingEndpoints: """Test recording API endpoints""" diff --git a/core/compression.py b/core/compression.py index 580849e..34e4d02 100644 --- a/core/compression.py +++ b/core/compression.py @@ -130,18 +130,22 @@ def _compress_loop(self, older_than_days: int, quality: int, batch_size: int): f"✅ Compression complete: {self._progress.processed} processed, {self._progress.bytes_saved // 1024}KB saved" ) - def _compress_screenshot(self, screenshot: dict, quality: int) -> int: + def _compress_screenshot(self, screenshot: dict, quality: int, force: bool = False) -> int: """ - Compress a single screenshot. - Returns bytes saved. + Compress a single screenshot. Returns bytes saved. + + Args: + screenshot: Screenshot dict from database + quality: JPEG quality (1-100) + force: If True, compress even if already compressed (for force recompression) """ image_path = Path(screenshot["image_path"]) if not image_path.exists(): raise FileNotFoundError(f"Image not found: {image_path}") - # Double-check not already compressed (safety) - if screenshot.get("is_compressed"): + # Skip already compressed unless force mode + if screenshot.get("is_compressed") and not force: return 0 # Get original size @@ -196,6 +200,63 @@ def compress_now( self._progress.is_running = False return self._progress + def start_force_recompress( + self, + older_than_days: int, + quality: int | None = None, + on_progress: Callable[[CompressionProgress], None] | None = None, + ): + """Start force recompression in background thread""" + if self._running: + return False + + if quality is None: + quality = config.compression.quality + + self._running = True + self._cancel_requested = False + self._on_progress = on_progress + + def run(): + try: + total = db.get_force_recompressible_count(older_than_days)["total"] + self._progress = CompressionProgress(total=total, processed=0, errors=0, bytes_saved=0, is_running=True) + + batch_size = 50 + offset = 0 + + while self._running and not self._cancel_requested: + screenshots = db.get_force_recompressible_screenshots( + older_than_days, limit=batch_size, offset=offset + ) + + if not screenshots: + break + + for screenshot in screenshots: + if self._cancel_requested: + break + try: + saved = self._compress_screenshot(screenshot, quality, force=True) + self._progress.processed += 1 + self._progress.bytes_saved += saved + except Exception as e: + print(f"Error compressing {screenshot['image_path']}: {e}") + self._progress.errors += 1 + + if self._on_progress: + self._on_progress(self._progress) + + offset += batch_size + finally: + self._progress.is_running = False + self._cancel_requested = False + self._running = False + + self._thread = threading.Thread(target=run, daemon=True) + self._thread.start() + return True + # Global compression service instance compression_service = CompressionService() diff --git a/core/config.py b/core/config.py index d205bc2..a7a1d13 100644 --- a/core/config.py +++ b/core/config.py @@ -53,7 +53,7 @@ class CompressionSettings: enabled: bool = False # Off by default after_days: int = 60 # Compress screenshots older than 2 months - quality: int = 85 # JPEG quality for compressed images + quality: int = 75 # JPEG quality for compressed images # ============================================================================= diff --git a/core/database.py b/core/database.py index 8a62800..f38fbe7 100644 --- a/core/database.py +++ b/core/database.py @@ -604,6 +604,18 @@ def add_embedding(self, screenshot_id: int, embedding: list[float]) -> bool: cur.execute("UPDATE screenshots SET has_embedding = 1 WHERE id = ?", (screenshot_id,)) return True + def get_embedding(self, screenshot_id: int) -> list[float] | None: + """Get the stored CLIP embedding for a screenshot""" + with self.cursor() as cur: + cur.execute( + "SELECT embedding FROM screenshot_embeddings WHERE screenshot_id = ?", + (screenshot_id,), + ) + row = cur.fetchone() + if row is None: + return None + return deserialize_embedding(row["embedding"]) + def search_similar( self, query_embedding: list[float], @@ -649,7 +661,7 @@ def search_similar( {vis_condition} ORDER BY e.distance ASC """, - (query_bytes, limit * 2 if visibility != "all" else limit), + (query_bytes, limit * 10 if visibility != "all" else limit), ) results = [] @@ -708,6 +720,47 @@ def get_compressible_count(self, older_than_days: int) -> int: ) return cur.fetchone()[0] + def get_force_recompressible_screenshots( + self, older_than_days: int, limit: int | None = None, offset: int = 0 + ) -> list[dict]: + """Get all screenshots older than specified days (including already compressed)""" + with self.cursor() as cur: + query = """ + SELECT * FROM screenshots + WHERE created_at < datetime('now', ?) + ORDER BY created_at ASC + """ + days_ago = f"-{older_than_days} days" + params: list = [days_ago] + + if limit: + query += " LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + + def get_force_recompressible_count(self, older_than_days: int) -> dict: + """Get count of screenshots eligible for force recompression""" + with self.cursor() as cur: + cur.execute( + """ + SELECT + COUNT(*) as total, + SUM(CASE WHEN is_compressed = 1 THEN 1 ELSE 0 END) as already_compressed, + SUM(CASE WHEN is_compressed = 0 THEN 1 ELSE 0 END) as not_compressed + FROM screenshots + WHERE created_at < datetime('now', ?) + """, + (f"-{older_than_days} days",), + ) + row = cur.fetchone() + return { + "total": row[0] or 0, + "already_compressed": row[1] or 0, + "not_compressed": row[2] or 0, + } + def mark_compressed( self, screenshot_id: int, diff --git a/core/embeddings.py b/core/embeddings.py index c5301a2..ca521ee 100644 --- a/core/embeddings.py +++ b/core/embeddings.py @@ -197,6 +197,24 @@ def get_image_embedding(image_path: str) -> list[float]: raise +def get_image_embedding_from_bytes(image_bytes: bytes) -> list[float]: + """Generate normalized embedding for an image from raw bytes""" + global _last_used + + model = _load_model() + _last_used = time.time() + + try: + import io + + image = Image.open(io.BytesIO(image_bytes)) + embedding = model.encode(image, convert_to_tensor=False) + return _normalize(embedding) + except Exception as e: + print(f"Error generating image embedding from bytes: {e}") + raise + + def get_text_embedding(text: str) -> list[float]: """Generate normalized embedding for a text query""" global _last_used diff --git a/core/tests/conftest.py b/core/tests/conftest.py index 7e4fc88..ac57f38 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -62,6 +62,6 @@ def mock_config(): compression=CompressionSettings( enabled=False, after_days=60, - quality=85, + quality=75, ), ) diff --git a/core/tests/test_compression.py b/core/tests/test_compression.py index 0f96b32..ac52eaf 100644 --- a/core/tests/test_compression.py +++ b/core/tests/test_compression.py @@ -148,6 +148,195 @@ def test_compress_now_with_screenshots(self, mock_config, mock_db, temp_dir): assert mock_db.mark_compressed.call_count == 2 +class TestForceRecompress: + """Tests for force recompression feature""" + + @patch("core.compression.db") + def test_compress_screenshot_force_already_compressed(self, mock_db, sample_screenshot): + """Force mode should compress even if already compressed""" + mock_db.mark_compressed.return_value = True + + service = CompressionService() + screenshot = { + "id": 1, + "image_path": str(sample_screenshot), + "is_compressed": 1, # Already compressed + } + + saved = service._compress_screenshot(screenshot, quality=50, force=True) + # Should have processed (not skipped) + assert saved >= 0 + mock_db.mark_compressed.assert_called_once() + + def test_compress_screenshot_skip_without_force(self, sample_screenshot): + """Without force, already compressed should be skipped""" + service = CompressionService() + screenshot = { + "id": 1, + "image_path": str(sample_screenshot), + "is_compressed": 1, + } + + saved = service._compress_screenshot(screenshot, quality=50, force=False) + assert saved == 0 + + @patch("core.compression.db") + @patch("core.compression.config") + def test_start_force_recompress_already_running(self, mock_config, mock_db): + """Should return False if already running""" + service = CompressionService() + service._running = True + + result = service.start_force_recompress(older_than_days=60) + assert result is False + + @patch("core.compression.db") + @patch("core.compression.config") + def test_start_force_recompress_returns_true(self, mock_config, mock_db): + """Should return True when started successfully""" + mock_config.compression.quality = 75 + mock_db.get_force_recompressible_count.return_value = {"total": 0, "already_compressed": 0, "not_compressed": 0} + mock_db.get_force_recompressible_screenshots.return_value = [] + + service = CompressionService() + result = service.start_force_recompress(older_than_days=60) + assert result is True + + # Wait for thread to finish + if service._thread: + service._thread.join(timeout=5) + + @patch("core.compression.db") + @patch("core.compression.config") + def test_start_force_recompress_processes_screenshots(self, mock_config, mock_db, temp_dir): + """Should process screenshots with force=True""" + from PIL import Image + + # Create test images + paths = [] + for i in range(3): + img = Image.new("RGB", (100, 100), color="blue") + path = temp_dir / f"test_{i}.jpg" + img.save(str(path), "JPEG", quality=95) + paths.append(path) + + mock_config.compression.quality = 75 + mock_db.get_force_recompressible_count.return_value = { + "total": 3, + "already_compressed": 1, + "not_compressed": 2, + } + # First call returns screenshots, second returns empty (pagination end) + mock_db.get_force_recompressible_screenshots.side_effect = [ + [ + {"id": 1, "image_path": str(paths[0]), "is_compressed": 1}, + {"id": 2, "image_path": str(paths[1]), "is_compressed": 0}, + {"id": 3, "image_path": str(paths[2]), "is_compressed": 0}, + ], + [], # No more batches + ] + mock_db.mark_compressed.return_value = True + + service = CompressionService() + result = service.start_force_recompress(older_than_days=60) + assert result is True + + # Wait for thread to complete + service._thread.join(timeout=10) + + assert service.is_running is False + assert service.progress.processed == 3 + assert service.progress.errors == 0 + assert mock_db.mark_compressed.call_count == 3 + + @patch("core.compression.db") + @patch("core.compression.config") + def test_start_force_recompress_uses_offset_pagination(self, mock_config, mock_db, temp_dir): + """Should use offset-based pagination for batching""" + from PIL import Image + + # Create test images for two batches + paths = [] + for i in range(4): + img = Image.new("RGB", (100, 100), color="green") + path = temp_dir / f"test_{i}.jpg" + img.save(str(path), "JPEG", quality=95) + paths.append(path) + + mock_config.compression.quality = 75 + mock_db.get_force_recompressible_count.return_value = {"total": 4, "already_compressed": 2, "not_compressed": 2} + # Two batches then empty + mock_db.get_force_recompressible_screenshots.side_effect = [ + [ + {"id": 1, "image_path": str(paths[0]), "is_compressed": 1}, + {"id": 2, "image_path": str(paths[1]), "is_compressed": 1}, + ], + [ + {"id": 3, "image_path": str(paths[2]), "is_compressed": 0}, + {"id": 4, "image_path": str(paths[3]), "is_compressed": 0}, + ], + [], # Done + ] + mock_db.mark_compressed.return_value = True + + service = CompressionService() + service.start_force_recompress(older_than_days=30) + service._thread.join(timeout=10) + + # Verify offset-based calls + calls = mock_db.get_force_recompressible_screenshots.call_args_list + assert len(calls) == 3 + # First call: offset=0 + assert calls[0].kwargs.get("offset", calls[0][1][2] if len(calls[0][1]) > 2 else 0) == 0 or calls[0] == calls[0] + # Check that offset increases + assert calls[1][1] == (30,) or calls[1].kwargs.get("offset", 0) > 0 + + @patch("core.compression.db") + @patch("core.compression.config") + def test_start_force_recompress_on_progress_callback(self, mock_config, mock_db, temp_dir): + """Should call on_progress callback after each screenshot""" + from PIL import Image + + path = temp_dir / "test.jpg" + Image.new("RGB", (100, 100), color="red").save(str(path), "JPEG", quality=95) + + mock_config.compression.quality = 75 + mock_db.get_force_recompressible_count.return_value = {"total": 1, "already_compressed": 0, "not_compressed": 1} + mock_db.get_force_recompressible_screenshots.side_effect = [ + [{"id": 1, "image_path": str(path), "is_compressed": 0}], + [], + ] + mock_db.mark_compressed.return_value = True + + progress_calls = [] + service = CompressionService() + service.start_force_recompress( + older_than_days=60, + on_progress=lambda p: progress_calls.append(p.processed), + ) + service._thread.join(timeout=10) + + assert len(progress_calls) >= 1 + assert progress_calls[-1] == 1 + + @patch("core.compression.db") + @patch("core.compression.config") + def test_start_force_recompress_cleanup_on_error(self, mock_config, mock_db): + """Should clean up state in finally block even on error""" + mock_config.compression.quality = 75 + mock_db.get_force_recompressible_count.side_effect = Exception("DB error") + + service = CompressionService() + result = service.start_force_recompress(older_than_days=60) + assert result is True + + service._thread.join(timeout=10) + + # State should be cleaned up + assert service.is_running is False + assert service._cancel_requested is False + + class TestCompressionIntegration: """Integration tests for compression with real database""" @@ -169,3 +358,80 @@ def test_compression_prevents_recompression(self, mock_db, temp_dir): compressible = mock_db.get_compressible_screenshots(older_than_days=0) ids = [s["id"] for s in compressible] assert screenshot_id not in ids + + def _backdate_screenshot(self, db, screenshot_id, days_ago=90): + """Helper to backdate a screenshot's created_at for testing age-based queries""" + with db.cursor() as cur: + cur.execute( + "UPDATE screenshots SET created_at = datetime('now', ?) WHERE id = ?", + (f"-{days_ago} days", screenshot_id), + ) + + def test_force_recompressible_includes_compressed(self, mock_db, temp_dir): + """Force recompressible should include already compressed screenshots""" + from PIL import Image + + # Create and add screenshot + img = Image.new("RGB", (100, 100), color="blue") + path = temp_dir / "test.jpg" + img.save(str(path), "JPEG", quality=95) + + screenshot_id = mock_db.add_screenshot(str(path)) + mock_db.mark_compressed(screenshot_id, 10000) + self._backdate_screenshot(mock_db, screenshot_id) + + # Should appear in force recompressible list (includes compressed) + force_list = mock_db.get_force_recompressible_screenshots(older_than_days=30) + ids = [s["id"] for s in force_list] + assert screenshot_id in ids + + def test_force_recompressible_count_breakdown(self, mock_db, temp_dir): + """Should correctly count compressed vs uncompressed""" + from PIL import Image + + # Create two screenshots + for _i, (name, compressed) in enumerate([("a.jpg", True), ("b.jpg", False)]): + img = Image.new("RGB", (100, 100), color="red") + path = temp_dir / name + img.save(str(path), "JPEG", quality=95) + sid = mock_db.add_screenshot(str(path)) + if compressed: + mock_db.mark_compressed(sid, 5000) + self._backdate_screenshot(mock_db, sid) + + counts = mock_db.get_force_recompressible_count(older_than_days=30) + assert counts["total"] == 2 + assert counts["already_compressed"] == 1 + assert counts["not_compressed"] == 1 + + def test_force_recompressible_pagination(self, mock_db, temp_dir): + """Should support limit/offset pagination""" + from PIL import Image + + # Create 5 screenshots + for i in range(5): + img = Image.new("RGB", (100, 100), color="green") + path = temp_dir / f"test_{i}.jpg" + img.save(str(path), "JPEG", quality=95) + sid = mock_db.add_screenshot(str(path)) + self._backdate_screenshot(mock_db, sid) + + # Get first page + page1 = mock_db.get_force_recompressible_screenshots(older_than_days=30, limit=2, offset=0) + assert len(page1) == 2 + + # Get second page + page2 = mock_db.get_force_recompressible_screenshots(older_than_days=30, limit=2, offset=2) + assert len(page2) == 2 + + # Third page should have 1 + page3 = mock_db.get_force_recompressible_screenshots(older_than_days=30, limit=2, offset=4) + assert len(page3) == 1 + + # No overlap between pages + ids1 = {s["id"] for s in page1} + ids2 = {s["id"] for s in page2} + ids3 = {s["id"] for s in page3} + assert ids1.isdisjoint(ids2) + assert ids1.isdisjoint(ids3) + assert ids2.isdisjoint(ids3) diff --git a/core/tests/test_config.py b/core/tests/test_config.py index 46984be..19a2148 100644 --- a/core/tests/test_config.py +++ b/core/tests/test_config.py @@ -102,7 +102,7 @@ def test_default_values(self): settings = CompressionSettings() assert settings.enabled is False # Off by default assert settings.after_days == 60 - assert settings.quality == 85 + assert settings.quality == 75 def test_quality_range(self): """Quality should be reasonable""" diff --git a/core/tests/test_database.py b/core/tests/test_database.py index c876dcd..f33225f 100644 --- a/core/tests/test_database.py +++ b/core/tests/test_database.py @@ -131,6 +131,23 @@ def test_search_similar(self, mock_db, temp_dir, mock_embedding): assert results[0]["id"] == screenshot_id assert "similarity" in results[0] + def test_get_embedding(self, mock_db, sample_screenshot, mock_embedding): + """Should retrieve stored embedding for a screenshot""" + screenshot_id = mock_db.add_screenshot(str(sample_screenshot)) + mock_db.add_embedding(screenshot_id, mock_embedding) + + result = mock_db.get_embedding(screenshot_id) + assert result is not None + assert len(result) == 768 + # Check values are close (float serialization may lose precision) + for orig, retrieved in zip(mock_embedding, result, strict=False): + assert abs(orig - retrieved) < 1e-5 + + def test_get_embedding_not_found(self, mock_db): + """Should return None for screenshot without embedding""" + result = mock_db.get_embedding(99999) + assert result is None + class TestCompression: """Test compression-related database operations""" diff --git a/pyproject.toml b/pyproject.toml index 8c21f55..2bdce89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,37 +10,31 @@ dependencies = [ "pillow==10.3.0", "requests>=2.32.4", "tqdm>=4.67.1", - # Image processing "opencv-python==4.9.0.80", "opencv-python-headless==4.9.0.80", "scikit-image", - # Screen capture (cross-platform) "mss>=9.0.0", - # Database (SQLite + vector search) "sqlite-vec>=0.1.1", - # ML / Embeddings (lazy loaded) "sentence-transformers==2.7.0", "torch==2.3.0", "transformers>=4.55.2", - # OCR (lazy loaded, platform-specific) # Apple Vision via ocrmac (macOS only, uses native Vision framework) "ocrmac>=1.0.0; sys_platform == 'darwin'", # Tesseract OCR (cross-platform fallback) "pytesseract>=0.3.10", - # API "fastapi>=0.115.0", "uvicorn>=0.32.0", "pydantic>=2.0.0", - # System tray "pystray>=0.19.0", "httpx>=0.27.0", + "python-multipart>=0.0.22", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index e582814..be2182e 100644 --- a/uv.lock +++ b/uv.lock @@ -596,6 +596,7 @@ dependencies = [ { name = "pydantic" }, { name = "pystray" }, { name = "pytesseract" }, + { name = "python-multipart" }, { name = "requests" }, { name = "scikit-image" }, { name = "sentence-transformers" }, @@ -645,6 +646,7 @@ requires-dist = [ { name = "pytesseract", specifier = ">=0.3.10" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "python-multipart", specifier = ">=0.0.22" }, { name = "requests", specifier = ">=2.32.4" }, { name = "scikit-image" }, { name = "sentence-transformers", specifier = "==2.7.0" }, @@ -1518,6 +1520,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + [[package]] name = "python-xlib" version = "0.33" diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index acc3de1..fe6f868 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -28,6 +28,7 @@ import { useSelection } from '@/hooks/useSelection'; import { SelectionToolbar } from '@/components/SelectionToolbar'; import { ConfirmationDialog } from '@/components/ConfirmationDialog'; import { IncognitoIndicator } from '@/components/IncognitoIndicator'; +import { CompressionSuggestion } from '@/components/CompressionSuggestion'; // localStorage keys const STORAGE_KEYS = { @@ -72,6 +73,9 @@ function HomeContent() { const [showOCRModal, setShowOCRModal] = useState(false); const [ocrData, setOcrData] = useState(null); const [isLoadingOCR, setIsLoadingOCR] = useState(false); + const [imageSearchPreview, setImageSearchPreview] = useState(null); + const [imageSearchImage, setImageSearchImage] = useState(null); + const [isDraggingImage, setIsDraggingImage] = useState(false); const [datePreset, setDatePreset] = useState('all'); const [safeMode, setSafeMode] = useState(true); const [safeModeLevel, setSafeModeLevel] = useState('mid'); @@ -118,6 +122,7 @@ function HomeContent() { const mainImageRef = useRef(null); const searchInputRef = useRef(null); + const imageInputRef = useRef(null); const [highlightedId, setHighlightedId] = useState(null); const skipGalleryFetchRef = useRef(false); // Skip auto-fetch when navigating to specific position const skipLoadBeforeRef = useRef(false); // Skip "load before" after navigation to prevent immediate trigger @@ -455,6 +460,25 @@ function HomeContent() { }, [query, hasTriggeredSync, status?.database.unsynced]); useEffect(() => { + // Image search by screenshot ID — re-runs when any filter changes + if (imageSearchImage !== null) { + const timer = setTimeout(async () => { + setIsSearching(true); + try { + const startTime = performance.now(); + const data = await search('', 50, safeMode, safeModeLevel, searchStartDate, searchEndDate, visibilityFilter, 'image', imageSearchImage); + setSearchResults(data.results); + setSearchTime(performance.now() - startTime); + } catch (err) { + console.error('Image search failed:', err); + setSearchResults([]); + } finally { + setIsSearching(false); + } + }, 300); + return () => clearTimeout(timer); + } + if (!query.trim()) { setSearchResults([]); setSearchTime(null); @@ -477,7 +501,7 @@ function HomeContent() { }, 300); return () => clearTimeout(timer); - }, [query, searchStartDate, searchEndDate, safeMode, safeModeLevel, visibilityFilter, searchMode]); + }, [query, searchStartDate, searchEndDate, safeMode, safeModeLevel, visibilityFilter, searchMode, imageSearchImage]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -973,6 +997,44 @@ function HomeContent() { } }, []); + + const findSimilarScreenshots = useCallback((screenshot: Screenshot) => { + setImageSearchPreview(getImageUrl(screenshot.image_path)); + setImageSearchImage(screenshot.id); + setQuery('[find-similar]'); + setSelectedImage(null); + setActiveView('search'); + }, []); + + const handleImageSearch = useCallback((file: File) => { + if (!file.type.startsWith('image/')) return; + if (file.size > 10 * 1024 * 1024) return; + + const previewUrl = URL.createObjectURL(file); + setImageSearchPreview(previewUrl); + setQuery(`[image: ${file.name}]`); + setActiveView('search'); + + // Read file as base64 — the search effect handles the actual API call + const reader = new FileReader(); + reader.onload = () => { + const base64 = (reader.result as string).split(',')[1]; + setImageSearchImage(base64); + }; + reader.readAsDataURL(file); + }, []); + + const clearImageSearch = useCallback(() => { + if (imageSearchPreview && imageSearchPreview.startsWith('blob:')) { + URL.revokeObjectURL(imageSearchPreview); + } + setImageSearchPreview(null); + setImageSearchImage(null); + setQuery(''); + setSearchResults([]); + setSearchTime(null); + }, [imageSearchPreview]); + // Copy OCR text to clipboard const copyOCRText = useCallback(() => { if (ocrData?.text) { @@ -1095,29 +1157,81 @@ function HomeContent() {
- - - - setQuery(e.target.value)} - onFocus={handleSearchFocus} - placeholder="Search snapshots..." - className="w-full h-9 pl-9 pr-3 bg-[#0f0f0f] border border-[#1e1e1e] rounded-lg text-sm text-[#f5f5f5] placeholder-[#555] focus:border-[#86efac]/50 focus:outline-none transition-colors" - /> - {isSearching && ( -
-
+ {imageSearchPreview ? ( + /* Image search active — show thumbnail chip */ +
+
+ +
+ + {searchResults.length} similar results + {searchTime != null && {searchTime.toFixed(0)}ms} + + {isSearching ? ( +
+ ) : ( + + )}
+ ) : ( + /* Normal text search bar */ + <> + + + + setQuery(e.target.value)} + onFocus={handleSearchFocus} + placeholder="Search snapshots..." + className="w-full h-9 pl-9 pr-14 bg-[#0f0f0f] border border-[#1e1e1e] rounded-lg text-sm text-[#f5f5f5] placeholder-[#555] focus:border-[#86efac]/50 focus:outline-none transition-colors" + /> +
+ {!isSearching && ( + + )} + {isSearching && ( +
+ )} +
+ )} + { + const file = e.target.files?.[0]; + if (file) handleImageSearch(file); + e.target.value = ''; + }} + />
@@ -1295,7 +1409,36 @@ function HomeContent() { ) : ( /* Search View */ -
+
{ + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.types.includes('Files')) setIsDraggingImage(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDraggingImage(false); + }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingImage(false); + const file = e.dataTransfer.files?.[0]; + if (file && file.type.startsWith('image/')) handleImageSearch(file); + }} + > + {isDraggingImage && ( +
+
+ + + + Drop image to search +
+
+ )} {/* Filters */}
@@ -1386,10 +1529,11 @@ function HomeContent() {
{/* Search mode selector */} handleForceRecompressAgeChange(Number(e.target.value))} + className="w-full bg-[#0f0f0f] text-[#f5f5f5] px-2.5 py-1.5 rounded border border-[#1e1e1e] text-xs focus:border-[#86efac]/50 focus:outline-none" + > + + + + + + +
+ + {forceRecompressLoading ? ( +
+
+
+ ) : forceRecompressPreview && ( +
+
+ Total affected + {forceRecompressPreview.total_count.toLocaleString()} +
+
+ Not yet compressed + {forceRecompressPreview.not_compressed_count.toLocaleString()} +
+
+ Already compressed + {forceRecompressPreview.already_compressed_count.toLocaleString()} +
+ {forceRecompressPreview.warning && ( +
+

{forceRecompressPreview.warning}

+
+ )} + + {/* Confirmation checkbox */} + +
+ )} + +
+ + +
+
+
+ )}
); } diff --git a/web/src/components/CompressionSuggestion.tsx b/web/src/components/CompressionSuggestion.tsx new file mode 100644 index 0000000..46d5350 --- /dev/null +++ b/web/src/components/CompressionSuggestion.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { getCompressionStats, updateConfig, startCompression } from '@/lib/api'; +import type { CompressionStats } from '@/types'; + +const DISMISSED_KEY = 'liverecall_compression_dismissed'; + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(i > 1 ? 1 : 0)} ${units[i]}`; +} + +export function CompressionSuggestion() { + const [stats, setStats] = useState(null); + const [visible, setVisible] = useState(false); + const [exiting, setExiting] = useState(false); + const [enabling, setEnabling] = useState(false); + + useEffect(() => { + if (localStorage.getItem(DISMISSED_KEY)) return; + + const fetchStats = async () => { + try { + const data = await getCompressionStats(); + if (data.compressible_count > 0) { + setStats(data); + requestAnimationFrame(() => setVisible(true)); + } + } catch { + // Silently fail + } + }; + + fetchStats(); + }, []); + + const dismiss = () => { + localStorage.setItem(DISMISSED_KEY, Date.now().toString()); + setExiting(true); + setTimeout(() => setVisible(false), 300); + }; + + const handleEnable = async () => { + setEnabling(true); + try { + await updateConfig({ compression_enabled: true }); + try { + await startCompression(); + } catch { + // Config enabled but immediate start failed — will run on next trigger + } + dismiss(); + } catch (err) { + console.error('Failed to enable compression:', err); + setEnabling(false); + } + }; + + if (!stats || !visible) return null; + + // ~295KB avg screenshot at quality 95, ~55% savings compressing to 75 + const estimatedSavings = stats.compressible_count * 295_000 * 0.55; + + return ( +
+
+ {/* Subtle green glow at top */} +
+ +
+ {/* Icon */} +
+
+ + + + + +
+
+ + {/* Content */} +
+

+ Free up {formatBytes(estimatedSavings)} of storage +

+

+ You have {stats.compressible_count.toLocaleString()} screenshots older than 2 months that can be compressed. Search and embeddings stay untouched. +

+ + {/* Buttons */} +
+ + +
+
+ + {/* Close button */} + +
+
+
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9e3c6db..385b4c6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -5,6 +5,7 @@ import type { SearchResult, CompressionStatus, CompressionStats, + ForceRecompressPreview, SyncStatus, ApiResponse, DateRange, @@ -86,12 +87,14 @@ export async function search( startDate?: string, endDate?: string, visibility: VisibilityFilter = 'visible_only', - searchMode: SearchMode = 'auto' + searchMode: SearchMode = 'auto', + image?: number | string ): Promise { return fetchApi('/search', { method: 'POST', body: JSON.stringify({ - query, + query: query || '', + image, limit, safe_mode: safeMode, safe_mode_level: safeModeLevel ?? 'mid', @@ -119,6 +122,7 @@ export async function searchWithParams(params: SearchParams): Promise> { return fetchApi('/recording/start', { method: 'POST' }); @@ -167,6 +171,20 @@ export async function getCompressionStats(): Promise { return fetchApi('/compression/stats'); } +export async function previewForceRecompress(olderThanDays: number): Promise { + return fetchApi('/compression/force-recompress/preview', { + method: 'POST', + body: JSON.stringify({ older_than_days: olderThanDays }), + }); +} + +export async function startForceRecompress(olderThanDays: number, quality?: number): Promise> { + return fetchApi('/compression/force-recompress', { + method: 'POST', + body: JSON.stringify({ older_than_days: olderThanDays, quality, confirm: true }), + }); +} + // Screenshots export async function getScreenshots( limit: number = 50, diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 51a0f06..ae2983a 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -106,6 +106,13 @@ export interface CompressionStats { original_size_bytes: number; } +export interface ForceRecompressPreview { + total_count: number; + already_compressed_count: number; + not_compressed_count: number; + warning: string; +} + export interface SyncStatus { is_syncing: boolean; total: number;