From 3a01fc4622065c419e0c4e5da1642443d9256f72 Mon Sep 17 00:00:00 2001 From: Vedank Purohit Date: Sat, 14 Feb 2026 18:12:50 +0530 Subject: [PATCH 1/5] feat: Add force recompress and update compression defaults Change default compression quality from 85 to 75 and age threshold from 60 to 90 days. Add force recompression feature that allows re-compressing all screenshots (including already compressed) older than a user-selected age, with preview and warning UI in Settings. - Update CompressionSettings defaults (quality 75, after_days 90) - Add force recompression DB methods with breakdown counts - Add batched force recompress with progress callbacks - Add API preview and execute endpoints with Pydantic schemas - Add force recompress modal in Settings with age selector and warnings --- api/routes/compression.py | 73 +++++++++++++++++++++ api/schemas.py | 31 +++++++++ core/compression.py | 70 ++++++++++++++++++-- core/config.py | 4 +- core/database.py | 39 +++++++++++ web/src/app/settings/page.tsx | 119 +++++++++++++++++++++++++++++++++- web/src/lib/api.ts | 15 +++++ web/src/types/index.ts | 7 ++ 8 files changed, 350 insertions(+), 8 deletions(-) diff --git a/api/routes/compression.py b/api/routes/compression.py index 73ef036..f5cf2a5 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,72 @@ 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, + ) + + compression_service.start_force_recompress( + older_than_days=request.older_than_days, + quality=request.quality, + ) + + return ForceRecompressResponse( + success=True, + message=f"Force recompression started for {counts['total']} screenshots", + affected_count=counts["total"], + ) diff --git a/api/schemas.py b/api/schemas.py index b5e7981..751dfbf 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""" diff --git a/core/compression.py b/core/compression.py index 580849e..d02887a 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,62 @@ 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 + processed_ids: set[int] = set() + + while self._running and not self._cancel_requested: + screenshots = db.get_force_recompressible_screenshots(older_than_days, limit=batch_size) + # Filter out already-processed in this run + screenshots = [s for s in screenshots if s["id"] not in processed_ids] + + if not screenshots: + break + + for screenshot in screenshots: + if self._cancel_requested: + break + processed_ids.add(screenshot["id"]) + 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) + 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..7b28063 100644 --- a/core/config.py +++ b/core/config.py @@ -52,8 +52,8 @@ class CompressionSettings: """Auto-compression settings for old screenshots""" enabled: bool = False # Off by default - after_days: int = 60 # Compress screenshots older than 2 months - quality: int = 85 # JPEG quality for compressed images + after_days: int = 90 # Compress screenshots older than 3 months + quality: int = 75 # JPEG quality for compressed images # ============================================================================= diff --git a/core/database.py b/core/database.py index 8a62800..82b4035 100644 --- a/core/database.py +++ b/core/database.py @@ -708,6 +708,45 @@ 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) -> 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" + + if limit: + query += " LIMIT ?" + cur.execute(query, (days_ago, limit)) + else: + cur.execute(query, (days_ago,)) + + 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/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx index 79bf35e..2e85880 100644 --- a/web/src/app/settings/page.tsx +++ b/web/src/app/settings/page.tsx @@ -13,8 +13,10 @@ import { startCompression, getCompressionStatus, getCompressionStats, + previewForceRecompress, + startForceRecompress, } from '@/lib/api'; -import type { AppConfig, SystemStatus, SyncStatus, CompressionStatus, CompressionStats, VisibilityFilter } from '@/types'; +import type { AppConfig, SystemStatus, SyncStatus, CompressionStatus, CompressionStats, VisibilityFilter, ForceRecompressPreview } from '@/types'; const STORAGE_KEYS = { VISIBILITY_FILTER: 'liverecall_visibility_filter', @@ -49,6 +51,10 @@ export default function SettingsPage() { const [compressionStatus, setCompressionStatus] = useState(null); const [compressionStats, setCompressionStats] = useState(null); const [visibilityFilter, setVisibilityFilter] = useState('visible_only'); + const [showForceRecompress, setShowForceRecompress] = useState(false); + const [forceRecompressAge, setForceRecompressAge] = useState(90); + const [forceRecompressPreview, setForceRecompressPreview] = useState(null); + const [forceRecompressLoading, setForceRecompressLoading] = useState(false); // Load visibility filter from localStorage useEffect(() => { @@ -125,6 +131,42 @@ export default function SettingsPage() { } }; + const handleOpenForceRecompress = async () => { + setShowForceRecompress(true); + setForceRecompressLoading(true); + try { + const preview = await previewForceRecompress(forceRecompressAge); + setForceRecompressPreview(preview); + } catch (err) { + console.error('Failed to preview:', err); + } finally { + setForceRecompressLoading(false); + } + }; + + const handleForceRecompressAgeChange = async (days: number) => { + setForceRecompressAge(days); + setForceRecompressLoading(true); + try { + const preview = await previewForceRecompress(days); + setForceRecompressPreview(preview); + } catch (err) { + console.error('Failed to preview:', err); + } finally { + setForceRecompressLoading(false); + } + }; + + const handleForceRecompress = async () => { + try { + await startForceRecompress(forceRecompressAge); + setShowForceRecompress(false); + setForceRecompressPreview(null); + } catch (err) { + console.error('Failed to start force recompression:', err); + } + }; + if (!config || !status) { return (
@@ -351,6 +393,15 @@ export default function SettingsPage() { {compressionStatus?.is_compressing ? 'Running...' : 'Compress'} + + + {/* Stats */} @@ -382,6 +433,72 @@ export default function SettingsPage() { {status.database.total_screenshots} snapshots
+ + {/* Force Recompress Modal */} + {showForceRecompress && ( +
+
+

Force Recompress

+ +
+ + +
+ + {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}

+
+ )} +
+ )} + +
+ + +
+
+
+ )}
); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9e3c6db..4da15f4 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, @@ -167,6 +168,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; From 687edd98c5bd0a21d56529faad3d8293113b1f26 Mon Sep 17 00:00:00 2001 From: Vedank Purohit Date: Sun, 15 Feb 2026 08:15:57 +0530 Subject: [PATCH 2/5] fix: Use offset-based batching for force recompress The previous approach fetched the same rows each batch since get_force_recompressible_screenshots doesn't filter on is_compressed. The processed_ids set filtered them all out, breaking the loop after the first batch. Switch to LIMIT/OFFSET pagination instead. --- core/compression.py | 11 ++++++----- core/database.py | 12 +++++++----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/core/compression.py b/core/compression.py index d02887a..34e4d02 100644 --- a/core/compression.py +++ b/core/compression.py @@ -223,12 +223,12 @@ def run(): self._progress = CompressionProgress(total=total, processed=0, errors=0, bytes_saved=0, is_running=True) batch_size = 50 - processed_ids: set[int] = set() + offset = 0 while self._running and not self._cancel_requested: - screenshots = db.get_force_recompressible_screenshots(older_than_days, limit=batch_size) - # Filter out already-processed in this run - screenshots = [s for s in screenshots if s["id"] not in processed_ids] + screenshots = db.get_force_recompressible_screenshots( + older_than_days, limit=batch_size, offset=offset + ) if not screenshots: break @@ -236,7 +236,6 @@ def run(): for screenshot in screenshots: if self._cancel_requested: break - processed_ids.add(screenshot["id"]) try: saved = self._compress_screenshot(screenshot, quality, force=True) self._progress.processed += 1 @@ -247,6 +246,8 @@ def run(): if self._on_progress: self._on_progress(self._progress) + + offset += batch_size finally: self._progress.is_running = False self._cancel_requested = False diff --git a/core/database.py b/core/database.py index 82b4035..f2324f3 100644 --- a/core/database.py +++ b/core/database.py @@ -708,7 +708,9 @@ 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) -> list[dict]: + 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 = """ @@ -717,13 +719,13 @@ def get_force_recompressible_screenshots(self, older_than_days: int, limit: int ORDER BY created_at ASC """ days_ago = f"-{older_than_days} days" + params: list = [days_ago] if limit: - query += " LIMIT ?" - cur.execute(query, (days_ago, limit)) - else: - cur.execute(query, (days_ago,)) + 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: From b78517541a72ffd910b79265be01a2b6b345c7c1 Mon Sep 17 00:00:00 2001 From: Vedank Purohit Date: Sun, 15 Feb 2026 11:25:46 +0530 Subject: [PATCH 3/5] feat: Add compression suggestion popup and UX improvements - Add floating banner on timeline when compressible screenshots exist - Banner offers one-click auto-compression enable with dismiss option - Uses localStorage to remember dismissal across sessions - Change default compression threshold from 90 to 60 days (2 months) - Add confirmation checkbox to force recompress modal for safety --- core/config.py | 2 +- web/src/app/page.tsx | 4 + web/src/app/settings/page.tsx | 23 +++- web/src/components/CompressionSuggestion.tsx | 137 +++++++++++++++++++ 4 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 web/src/components/CompressionSuggestion.tsx diff --git a/core/config.py b/core/config.py index 7b28063..a7a1d13 100644 --- a/core/config.py +++ b/core/config.py @@ -52,7 +52,7 @@ class CompressionSettings: """Auto-compression settings for old screenshots""" enabled: bool = False # Off by default - after_days: int = 90 # Compress screenshots older than 3 months + after_days: int = 60 # Compress screenshots older than 2 months quality: int = 75 # JPEG quality for compressed images diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index acc3de1..d75239c 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 = { @@ -1893,6 +1894,9 @@ function HomeContent() { confirmVariant="warning" isLoading={isBulkOperationLoading} /> + + {/* Compression suggestion banner */} + ); } diff --git a/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx index 2e85880..ed9ea35 100644 --- a/web/src/app/settings/page.tsx +++ b/web/src/app/settings/page.tsx @@ -55,6 +55,7 @@ export default function SettingsPage() { const [forceRecompressAge, setForceRecompressAge] = useState(90); const [forceRecompressPreview, setForceRecompressPreview] = useState(null); const [forceRecompressLoading, setForceRecompressLoading] = useState(false); + const [forceRecompressConfirmed, setForceRecompressConfirmed] = useState(false); // Load visibility filter from localStorage useEffect(() => { @@ -133,6 +134,7 @@ export default function SettingsPage() { const handleOpenForceRecompress = async () => { setShowForceRecompress(true); + setForceRecompressConfirmed(false); setForceRecompressLoading(true); try { const preview = await previewForceRecompress(forceRecompressAge); @@ -474,14 +476,27 @@ export default function SettingsPage() { {forceRecompressPreview.already_compressed_count.toLocaleString()} {forceRecompressPreview.warning && ( -
-

{forceRecompressPreview.warning}

+
+

{forceRecompressPreview.warning}

)} + + {/* Confirmation checkbox */} +
)} -
+
+ +
+
+ + {/* Close button */} + + + + + ); +} From 1894e7e740a7bc81e396cd871e83a2a7a9a345c8 Mon Sep 17 00:00:00 2001 From: Vedank Purohit Date: Sun, 15 Feb 2026 11:29:26 +0530 Subject: [PATCH 4/5] fix: Address code review findings for compression feature - Check start_force_recompress return value to handle TOCTOU race - Save localStorage dismissal immediately, not after animation delay - Reset confirmation checkbox when closing force recompress modal - Handle partial failure: dismiss popup even if startCompression fails after config is successfully enabled --- api/routes/compression.py | 9 ++++++++- web/src/app/settings/page.tsx | 2 +- web/src/components/CompressionSuggestion.tsx | 12 +++++++----- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/api/routes/compression.py b/api/routes/compression.py index f5cf2a5..90a2c41 100644 --- a/api/routes/compression.py +++ b/api/routes/compression.py @@ -182,11 +182,18 @@ async def start_force_recompress(request: ForceRecompressRequest): affected_count=0, ) - compression_service.start_force_recompress( + 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", diff --git a/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx index ed9ea35..ce37ede 100644 --- a/web/src/app/settings/page.tsx +++ b/web/src/app/settings/page.tsx @@ -498,7 +498,7 @@ export default function SettingsPage() {