Skip to content
Open
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
52 changes: 51 additions & 1 deletion backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,58 @@
CREATE INDEX IF NOT EXISTS idx_photos_dev_status ON photos(dev_status);
""",
),
(
2,
"Add CHECK constraint to dev_status column",
# SQLite cannot ALTER an existing column to add a CHECK constraint, so
# the table is rebuilt with the constraint in place (the classic SQLite
# "12-step" procedure). COALESCE guards against any pre-existing NULLs.
"""
CREATE TABLE photos_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
original_path TEXT NOT NULL,
thumb_path TEXT NOT NULL,
file_hash TEXT UNIQUE NOT NULL,
date_taken TEXT,
width INTEGER,
height INTEGER,
iso INTEGER,
aperture REAL,
shutter_speed TEXT,
focal_length TEXT,
lens TEXT,
camera TEXT,
rating INTEGER DEFAULT 0 CHECK(rating >= 0 AND rating <= 5),
status TEXT DEFAULT 'pending'
CHECK(status IN ('pending', 'keep', 'discard')),
dev_status TEXT DEFAULT 'none'
CHECK(dev_status IN ('none', 'consideration', 'in_darkroom', 'developed')),
created_at TEXT DEFAULT (datetime('now'))
);

INSERT INTO photos_new
(id, filename, original_path, thumb_path, file_hash, date_taken,
width, height, iso, aperture, shutter_speed, focal_length, lens,
camera, rating, status, dev_status, created_at)
SELECT
id, filename, original_path, thumb_path, file_hash, date_taken,
width, height, iso, aperture, shutter_speed, focal_length, lens,
camera, rating, status, COALESCE(dev_status, 'none'), created_at
FROM photos;

DROP TABLE photos;
ALTER TABLE photos_new RENAME TO photos;

CREATE INDEX IF NOT EXISTS idx_photos_rating ON photos(rating);
CREATE INDEX IF NOT EXISTS idx_photos_status ON photos(status);
CREATE INDEX IF NOT EXISTS idx_photos_date ON photos(date_taken);
CREATE INDEX IF NOT EXISTS idx_photos_hash ON photos(file_hash);
CREATE INDEX IF NOT EXISTS idx_photos_dev_status ON photos(dev_status);
""",
),
# Add future migrations here, for example:
# (2, "Add tags table", "CREATE TABLE IF NOT EXISTS tags ..."),
# (3, "Add tags table", "CREATE TABLE IF NOT EXISTS tags ..."),
]


Expand Down
8 changes: 7 additions & 1 deletion backend/ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ def ingest_file(filepath: str) -> bool:
safe_copy(filepath, dest_path)
logger.info(" Copied to: %s", dest_path)

# organize_path() may have renamed the file to avoid a collision with an
# existing file of the same name — use the resolved name from here on so
# the DB stays in sync with what's actually on disk (filename is used as
# a lookup key elsewhere, e.g. the darkroom copy path).
stored_filename = os.path.basename(dest_path)

# 5. Generate thumbnail
thumb_filename = f"{file_hash}.jpg"
thumb_path = os.path.join(THUMB_DIR, thumb_filename)
Expand All @@ -94,7 +100,7 @@ def ingest_file(filepath: str) -> bool:

# 6. Build record and insert into DB
photo_record = {
"filename": filename,
"filename": stored_filename,
"original_path": dest_path,
"thumb_path": thumb_path,
"file_hash": file_hash,
Expand Down
24 changes: 21 additions & 3 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@
)
from backend.utils.exif_utils import extract_metadata # noqa: E402
from backend.utils.file_utils import compute_hash # noqa: E402
from backend.utils.raw_processing import is_raw_file, process_raw # noqa: E402
from backend.utils.raw_processing import ( # noqa: E402
SUPPORTED_RAW_EXTENSIONS,
is_raw_file,
process_raw,
)

# Configure logging
logging.basicConfig(
Expand Down Expand Up @@ -517,6 +521,20 @@ def delete_photo_endpoint(photo_id: int):
return None


def _if_none_match_hits(header_value: str | None, etag: str) -> bool:
"""Check an If-None-Match header against an ETag.

The header may carry a comma-separated list of ETags (e.g. from browser
prefetch/revalidation across tabs) or "*"; a plain "==" comparison only
matches the single-value case.
"""
if not header_value:
return False
if header_value.strip() == "*":
return True
return etag in (candidate.strip() for candidate in header_value.split(","))


@app.get("/api/files/thumb/{photo_id}")
def serve_thumbnail(photo_id: int, request: Request):
"""Serve a thumbnail JPEG for a photo with aggressive immutable caching.
Expand All @@ -536,7 +554,7 @@ def serve_thumbnail(photo_id: int, request: Request):
etag = f'"sha256-{photo["file_hash"]}"'

# Return 304 Not Modified if the browser already has this exact thumbnail
if request.headers.get("If-None-Match") == etag:
if _if_none_match_hits(request.headers.get("If-None-Match"), etag):
return Response(status_code=304, headers={
"Cache-Control": "public, max-age=31536000, immutable",
"ETag": etag,
Expand Down Expand Up @@ -573,7 +591,7 @@ def serve_original(photo_id: int, request: Request):
last_modified_str = email.utils.formatdate(stat.st_mtime, usegmt=True)

# Honour conditional GET
if request.headers.get("If-None-Match") == etag:
if _if_none_match_hits(request.headers.get("If-None-Match"), etag):
return Response(status_code=304, headers={
"Cache-Control": "private, no-store",
"ETag": etag,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ function App() {
photo={selectedPhoto}
onClose={handleCloseLightbox}
onPrev={selectedIndex > 0 ? handlePrev : null}
onNext={selectedIndex < photos.length - 1 ? handleNext : null}
onNext={(selectedIndex < photos.length - 1 || hasMore) ? handleNext : null}
onRate={handleRate}
onStatus={handleStatus}
onDevStatus={handleDevStatus}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function Settings({ onClose, onRebuildComplete }) {
if (!data.running) {
setIsRebuilding(false)
clearInterval(pollRef.current)
onRebuildComplete?.()
onRebuildComplete?.(data)
}
} catch (err) {
console.error('Failed to poll rebuild status:', err)
Expand Down
Loading