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
80 changes: 80 additions & 0 deletions api/routes/compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
CompressionStartResponse,
CompressionStats,
CompressionStatus,
ForceRecompressPreviewRequest,
ForceRecompressPreviewResponse,
ForceRecompressRequest,
ForceRecompressResponse,
)
from core.compression import CompressionProgress, compression_service
from core.config import config
Expand Down Expand Up @@ -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"],
)
31 changes: 31 additions & 0 deletions api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down
193 changes: 193 additions & 0 deletions api/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
Loading