diff --git a/backend/database.py b/backend/database.py index 56b4ef5..d5ec647 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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 ..."), ] diff --git a/backend/ingestion.py b/backend/ingestion.py index 7f954e8..efd6cfe 100644 --- a/backend/ingestion.py +++ b/backend/ingestion.py @@ -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) @@ -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, diff --git a/backend/main.py b/backend/main.py index bafacf8..69d67fc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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( @@ -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. @@ -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, @@ -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, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 02f2438..2887bd8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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} diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx index c2a55cb..5653c9a 100644 --- a/frontend/src/components/Settings.jsx +++ b/frontend/src/components/Settings.jsx @@ -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)