diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index 741e93d3c..8775ef104 100644 --- a/backend/app/config/settings.py +++ b/backend/app/config/settings.py @@ -15,11 +15,10 @@ os.path.join(os.path.dirname(__file__), "..", "models", "ONNX_Exports") ) -# Microservice URLs SYNC_MICROSERVICE_URL = "http://localhost:52124" -# The album share listener. Unlike the two services above this one binds -# 0.0.0.0, so it is reachable by anything that can route to this machine. +# The album share listener. Unlike the backend and sync service it binds 0.0.0.0, +# so anything that can route to this machine can reach it. SHARE_SERVER_PORT = 52125 CONFIDENCE_PERCENT = 0.6 @@ -180,13 +179,12 @@ def _get_env_int( SIGLIP2_TEXT_MAX_LENGTH = 64 SIGLIP2_TOKENIZER_PAD_ID = 0 SIGLIP2_TOKENIZER_PAD_TOKEN = "" -# bare-noun queries score low in absolute terms; 0.02 measured to cut true positives. +# Bare-noun queries score low in absolute terms; 0.02 measurably cut true positives. SIGLIP2_MATCH_THRESHOLD = _get_env_float("SIGLIP2_MATCH_THRESHOLD", 0.01, min_value=0.0) -# Curated-vocabulary pre-scoring. Ensembled label vectors score ~2 orders of -# magnitude below live queries, so these floors are NOT comparable to -# SIGLIP2_MATCH_THRESHOLD. Calibrated against a 151-image stratified eval set; -# see backend/scripts/vocabulary/calibration_report.json. +# Ensembled label vectors score ~2 orders of magnitude below live queries, so +# these floors are NOT comparable to SIGLIP2_MATCH_THRESHOLD. Calibrated against +# a 151-image eval set; see backend/scripts/vocabulary/calibration_report.json. SEMANTIC_SCORE_TOP_K = _get_env_int("SEMANTIC_SCORE_TOP_K", 15, min_value=1) # stricter cut for tag chips / tag lists; stored rows keep the full top-K SEMANTIC_DISPLAY_TOP_K = _get_env_int("SEMANTIC_DISPLAY_TOP_K", 5, min_value=1) @@ -198,12 +196,9 @@ def _get_env_int( } SEMANTIC_DEFAULT_THRESHOLD = 5e-05 -# Video keyframe sampling. One frame every N seconds instead of per-frame -# inference (30fps = 1800 forward passes per minute of video). The interval is -# stretched when a video would exceed the cap, so cost per video is bounded. -# The bounds are the single source of truth for both the sampler default here -# and the user-preferences API schema, so the API can never accept an interval -# the sampler would reject. +# One frame every N seconds instead of per-frame inference (30fps = 1800 passes +# per video-minute), stretched to keep cost per video bounded. These bounds also +# back the user-preferences schema, so the API cannot accept what the sampler rejects. VIDEO_FRAME_INTERVAL_MIN = 0.5 VIDEO_FRAME_INTERVAL_MAX = 300.0 VIDEO_FRAME_INTERVAL_SECONDS = _get_env_float( diff --git a/backend/app/database/albums.py b/backend/app/database/albums.py index 65bed9171..25e8d9f15 100644 --- a/backend/app/database/albums.py +++ b/backend/app/database/albums.py @@ -81,10 +81,9 @@ def db_create_albums_table() -> None: if "cover_image_path" not in columns: cursor.execute("ALTER TABLE albums ADD COLUMN cover_image_path TEXT") if "created_at" not in columns: - # No default: SQLite rejects a non-constant one on ALTER TABLE, and - # stamping every existing album with the upgrade time would be a - # date that never happened. They stay NULL and read as oldest, - # which their insertion order already reflects. + # No default: SQLite rejects a non-constant one here, and the upgrade + # time is a date that never happened. NULL reads as oldest, which + # insertion order already reflects. cursor.execute("ALTER TABLE albums ADD COLUMN created_at DATETIME") if "updated_at" not in columns: cursor.execute("ALTER TABLE albums ADD COLUMN updated_at DATETIME") @@ -244,7 +243,6 @@ def db_update_album( cursor = conn.cursor() try: if password is not None: - # Update with new password password_hash = bcrypt.hashpw( password.encode("utf-8"), bcrypt.gensalt() ).decode("utf-8") @@ -258,7 +256,6 @@ def db_update_album( (album_name, description, int(is_locked), password_hash, album_id), ) else: - # Update without changing password cursor.execute( """ UPDATE albums @@ -351,14 +348,12 @@ def db_add_images_to_album(album_id: str, image_ids: list[str]): Safely adds images to an album using parameterized queries. Maintains UUID support and uses efficient single queries. """ - # Validate input type if not isinstance(image_ids, list): raise ValueError("image_ids must be a list of IDs") - # Remove integer conversion - keep IDs as strings for UUID support + # IDs are UUID strings, so they are never cast to int. sanitized_ids = [] for img_id in image_ids: - # Basic validation - ensure it's a non-empty string if isinstance(img_id, str) and img_id.strip(): sanitized_ids.append(img_id.strip()) @@ -368,16 +363,14 @@ def db_add_images_to_album(album_id: str, image_ids: list[str]): with get_db_connection() as conn: cursor = conn.cursor() - # Generate placeholders safely based on list length placeholders = ",".join(["?"] * len(sanitized_ids)) query = f"SELECT id FROM images WHERE id IN ({placeholders})" - cursor.execute(query, sanitized_ids) # Pass string IDs directly + cursor.execute(query, sanitized_ids) valid_images = [row[0] for row in cursor.fetchall()] if not valid_images: raise ValueError("None of the provided image IDs exist in the database.") - # Insert into album_images using executemany cursor.executemany( "INSERT OR IGNORE INTO album_images (album_id, image_id) VALUES (?, ?)", [(album_id, img_id) for img_id in valid_images], diff --git a/backend/app/database/faces.py b/backend/app/database/faces.py index 9c19d4208..e710672f2 100644 --- a/backend/app/database/faces.py +++ b/backend/app/database/faces.py @@ -7,7 +7,6 @@ logger = get_logger(__name__) -# Type definitions FaceId = int ImageId = str FrameId = str @@ -73,10 +72,9 @@ def db_create_faces_table() -> None: ) """ ) - # Legacy databases only: they predate the column above, and - # CREATE IF NOT EXISTS will not add it to a table that already exists. - # No CHECK enforcing the exclusive arc -- SQLite cannot ALTER one in, so - # fresh and migrated databases would end up with different schemas. + # Legacy databases predate frame_id, and CREATE IF NOT EXISTS won't add + # it. No CHECK for the exclusive arc: SQLite can't ALTER one in, so fresh + # and migrated schemas would differ. cursor.execute("PRAGMA table_info(faces)") if "frame_id" not in {row[1] for row in cursor.fetchall()}: cursor.execute( @@ -181,7 +179,6 @@ def db_insert_face_embeddings( try: embeddings_json = json.dumps([emb.tolist() for emb in embeddings]) - # Convert bbox to JSON string if provided bbox_json = json.dumps(bbox) if bbox is not None else None cursor.execute( @@ -220,7 +217,6 @@ def db_insert_face_embeddings_by_image_id( cluster_id: Cluster ID(s) for the face(s) (optional) """ - # Handle multiple faces in one image if ( isinstance(embeddings, list) and len(embeddings) > 0 @@ -247,7 +243,6 @@ def db_insert_face_embeddings_by_image_id( face_ids.append(face_id) return face_ids else: - # Single face return db_insert_face_embeddings( image_id, cast(FaceEmbedding, embeddings), @@ -365,7 +360,6 @@ def db_get_faces_unassigned_clusters() -> ( faces = [] for row in rows: face_id, image_id, frame_id, embeddings_json = row - # Convert JSON string back to numpy array embeddings = np.array(json.loads(embeddings_json)) faces.append( { @@ -410,7 +404,6 @@ def db_get_all_faces_with_cluster_names() -> ( faces = [] for row in rows: face_id, image_id, frame_id, embeddings_json, cluster_name = row - # Convert JSON string back to numpy array embeddings = np.array(json.loads(embeddings_json)) faces.append( { @@ -455,9 +448,8 @@ def db_update_face_cluster_ids_batch( cursor = conn.cursor() assert cursor is not None - # 1. Prepare update data outside the DB transaction. - # mapping.get() calls could raise AttributeError if mappings are malformed; - # these are not sqlite3 errors and must be caught before the transaction. + # Build rows before the transaction: a malformed mapping raises AttributeError, + # which the sqlite3.Error handler below would not catch. try: update_data = [ (mapping.get("cluster_id"), mapping.get("face_id")) @@ -469,7 +461,7 @@ def db_update_face_cluster_ids_batch( logger.error(f"Failed to prepare face cluster update data: {e}") raise - # 2. Database transaction — only pure DB operations here, so sqlite3.Error is safe. + # Only DB calls in here, so catching sqlite3.Error is enough. try: cursor.executemany( """ @@ -536,21 +528,17 @@ def db_get_cluster_mean_embeddings() -> List[Dict[str, Union[str, FaceEmbedding] if not rows: return [] - # Group embeddings by cluster_id cluster_embeddings: Dict[ClusterId, List[FaceEmbedding]] = {} for row in rows: cluster_id, embeddings_json = row - # Convert JSON string back to numpy array embeddings = np.array(json.loads(embeddings_json)) if cluster_id not in cluster_embeddings: cluster_embeddings[cluster_id] = [] cluster_embeddings[cluster_id].append(embeddings) - # Calculate mean embeddings for each cluster cluster_means = [] for cluster_id, embeddings_list in cluster_embeddings.items(): - # Stack all embeddings for this cluster and calculate mean stacked_embeddings = np.stack(embeddings_list) mean_embedding = np.mean(stacked_embeddings, axis=0) diff --git a/backend/app/database/image_embeddings.py b/backend/app/database/image_embeddings.py index 5a524d1c0..8ae68d27e 100644 --- a/backend/app/database/image_embeddings.py +++ b/backend/app/database/image_embeddings.py @@ -43,7 +43,6 @@ def db_upsert_image_embeddings(rows: List[Tuple[str, str, np.ndarray]]): conn = _connect() cursor = conn.cursor() - # Convert each embedding db_rows = [ ( image_id, diff --git a/backend/app/database/memories.py b/backend/app/database/memories.py index 279520319..060b47365 100644 --- a/backend/app/database/memories.py +++ b/backend/app/database/memories.py @@ -1,5 +1,5 @@ -# This DB module stores curated memories: persisted, scored collections of -# images surfaced proactively rather than browsed. +# Curated memories: persisted, scored collections of images that PictoPy +# surfaces on its own rather than waiting to be browsed. import json import sqlite3 from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple, TypedDict @@ -137,10 +137,8 @@ def db_create_memories_table() -> None: """ ) - # Videos get their own table rather than a media_type column on - # memory_images: the real cascade is the reason that table exists, and - # one id column cannot reference two parents. sort_order is shared - # across both, so a story still reads in one chronological sequence. + # Its own table rather than a media_type column: one id column cannot + # reference two parents. sort_order is shared, so a story stays in sequence. cursor.execute( """ CREATE TABLE IF NOT EXISTS memory_videos ( @@ -1448,10 +1446,8 @@ def db_get_images_in_period( conn = _connect() conn.row_factory = sqlite3.Row cursor = conn.cursor() - # Compared through datetime() rather than as raw strings: stored - # timestamps use a space separator while ISO bounds use "T", and "T" - # sorts after every digit, so a direct comparison silently matches - # nothing. + # Through datetime(), not raw strings: stored timestamps use a space where + # ISO bounds use "T", which sorts after every digit and matches nothing. cursor.execute( """ SELECT id, path, thumbnailPath, captured_at, latitude, longitude, diff --git a/backend/app/database/semantic_labels.py b/backend/app/database/semantic_labels.py index 18d1c0c24..3f54ec671 100644 --- a/backend/app/database/semantic_labels.py +++ b/backend/app/database/semantic_labels.py @@ -18,21 +18,18 @@ def db_create_semantic_labels_table(): conn = _connect() cursor = conn.cursor() - # Migrate the pre-vocabulary shell schema. It shipped with no writer, - # so the table is guaranteed empty: drop-and-recreate instead of - # ALTER. image_semantic_labels (also never written) is superseded by - # image_classes rows + image_classes.score. + # The pre-vocabulary schema shipped with no writer, so it is guaranteed + # empty: drop and recreate rather than ALTER. image_semantic_labels, also + # never written, is superseded by image_classes.score. cursor.execute("PRAGMA table_info(semantic_labels)") columns = {row[1] for row in cursor.fetchall()} if columns and "descriptions" not in columns: cursor.execute("DROP TABLE semantic_labels") cursor.execute("DROP TABLE IF EXISTS image_semantic_labels") - # Definition + cache table for the curated vocabulary. class_id is - # shared with mappings so tag consumers (image_classes joins) treat - # semantic labels exactly like YOLO classes. descriptions (JSON - # array) are the source of truth; label_embedding caches their - # renormalized mean for embedding_model_version. + # class_id is shared with mappings so tag consumers treat semantic labels + # exactly like YOLO classes. descriptions are the source of truth; + # label_embedding just caches their renormalized mean. cursor.execute( """ CREATE TABLE IF NOT EXISTS semantic_labels ( @@ -48,11 +45,9 @@ def db_create_semantic_labels_table(): """ ) - # Display cut: tag-list queries join this view instead of - # image_classes, so chips show all YOLO tags but only the - # top-SEMANTIC_DISPLAY_TOP_K semantic tags per image. Search - # matching still uses the full table (stored top-K). Recreated at - # startup so setting changes apply without re-scoring. + # Chips join this view so they show all YOLO tags but only the top few + # semantic ones; search still uses the full table. Recreated at startup so + # a setting change applies without re-scoring. from app.config.settings import SEMANTIC_DISPLAY_TOP_K cursor.execute("DROP VIEW IF EXISTS image_classes_display") diff --git a/backend/app/database/videos.py b/backend/app/database/videos.py index 6d33173d4..b6726b4e7 100644 --- a/backend/app/database/videos.py +++ b/backend/app/database/videos.py @@ -1,20 +1,16 @@ -# Standard library imports import sqlite3 from typing import Any, List, Mapping, Optional, Tuple, TypedDict, Union import json from datetime import datetime -# App-specific imports from app.config.settings import ( DATABASE_PATH, ) from app.logging.setup_logging import get_logger -# Initialize logger logger = get_logger(__name__) -# Type definitions VideoId = str VideoPath = str FolderId = str @@ -46,10 +42,8 @@ def db_create_videos_table() -> None: conn = _connect() cursor = conn.cursor() - # Videos are kept separate from images by design; isTagged tracks the - # keyframe sampling pass (see video_frames). thumbnailPath is nullable: - # videos whose codec OpenCV cannot decode are still indexed (frontend - # shows a placeholder). + # isTagged tracks the keyframe sampling pass (see video_frames). thumbnailPath + # is nullable: a codec OpenCV cannot decode is still indexed, with a placeholder. cursor.execute( """ CREATE TABLE IF NOT EXISTS videos ( diff --git a/backend/app/database/xmp_export_state.py b/backend/app/database/xmp_export_state.py index 0be03ebf0..645de5c7c 100644 --- a/backend/app/database/xmp_export_state.py +++ b/backend/app/database/xmp_export_state.py @@ -36,10 +36,9 @@ def dim(row: str, key: str) -> str: ) -# Every write that can change what export would put in an image. Triggers only -# ever UPDATE existing state rows: inserting one while an image is being -# cascade-deleted would violate its foreign key. "UPDATE OF" fires even when -# the value is unchanged, so each column trigger also checks for a real change. +# Every write that can change an image's exported data. Triggers only UPDATE state +# rows, since an INSERT mid cascade-delete would break the foreign key. "UPDATE OF" +# fires even on unchanged values, so each column trigger checks for a real change. _TRIGGERS: List[Tuple[str, str]] = [ ( "xmp_images_insert", diff --git a/backend/app/database/yolo_mapping.py b/backend/app/database/yolo_mapping.py index e3be1b4b1..0e2b877c0 100644 --- a/backend/app/database/yolo_mapping.py +++ b/backend/app/database/yolo_mapping.py @@ -4,10 +4,6 @@ def db_create_YOLO_classes_table(): - # print current directory: - import os - - print(os.getcwd()) conn = None try: conn = sqlite3.connect(DATABASE_PATH) @@ -21,10 +17,9 @@ def db_create_YOLO_classes_table(): """ ) for class_id, name in enumerate(class_names): - # Upsert, not INSERT OR REPLACE: a replace deletes the row first, - # which cascades to every image_classes row once foreign keys are - # on. Writing only on a real change also keeps startup from - # marking every tagged image for metadata export. + # Upsert, not INSERT OR REPLACE: a replace deletes the row first, which + # cascades to image_classes once foreign keys are on. Writing only on a + # real change keeps startup from marking every tagged image for export. cursor.execute( "INSERT INTO mappings (class_id, name) VALUES (?, ?) " "ON CONFLICT(class_id) DO UPDATE SET name = excluded.name " diff --git a/backend/app/logging/setup_logging.py b/backend/app/logging/setup_logging.py index e8f6b38b9..3fd89d21e 100644 --- a/backend/app/logging/setup_logging.py +++ b/backend/app/logging/setup_logging.py @@ -62,17 +62,14 @@ def __init__( def format(self, record: logging.LogRecord) -> str: """Format the log record with colors and component prefix.""" - # Add component information to the record component_prefix = self.component_config.get("prefix", "") record.component = component_prefix - # Format the message formatted_message = super().format(record) if not self.use_colors: return formatted_message - # Add color to the component prefix component_color = self.component_config.get("color", "") if component_color and component_color in self.COLORS: component_start = formatted_message.find(f"[{component_prefix}]") @@ -86,7 +83,6 @@ def format(self, record: logging.LogRecord) -> str: + formatted_message[component_end:] ) - # Add color to the log level level_color = self.level_colors.get(record.levelname, "") if level_color: # Handle comma-separated color specs like "red,bg_white" @@ -147,7 +143,6 @@ def setup_logging(component_name: str, environment: Optional[str] = None) -> Non ) return - # Get environment settings if not environment: environment = os.environ.get( "ENV", config.get("default_environment", "development") @@ -158,32 +153,26 @@ def setup_logging(component_name: str, environment: Optional[str] = None) -> Non use_colors = env_settings.get("colored_output", True) console_logging = env_settings.get("console_logging", True) - # Get component configuration component_config = config.get("components", {}).get( component_name, {"prefix": component_name.upper(), "color": "white"} ) - # Configure root logger root_logger = logging.getLogger() root_logger.setLevel(log_level) - # Clear existing handlers for handler in root_logger.handlers[:]: root_logger.removeHandler(handler) - # Configure specific loggers if defined in environment settings if "loggers" in env_settings: for logger_name, logger_config in env_settings["loggers"].items(): logger = logging.getLogger(logger_name) if "level" in logger_config: logger.setLevel(getattr(logging, logger_config["level"], log_level)) - # Set up console handler if console_logging: console_handler = logging.StreamHandler(sys.stdout) console_handler.setLevel(log_level) - # Create formatter with component and color information fmt = ( config.get("formatters", {}) .get("default", {}) @@ -235,19 +224,17 @@ def emit(self, record: logging.LogRecord) -> None: Args: record: The log record to process """ - # Get the appropriate module name module_name = record.name if module_name.startswith("uvicorn"): module_name = "uvicorn" elif "." in module_name: module_name = module_name.split(".")[-1] - # Create a message that includes the original module in the format msg = record.getMessage() record.msg = f"[{module_name}] {msg}" record.args = () - # Clear exception / stack info to avoid duplicate traces + # Cleared so the same traceback is not printed twice. record.exc_info = None record.stack_info = None @@ -266,10 +253,8 @@ def configure_uvicorn_logging(component_name: str) -> None: """ import logging.config - # Create an intercept handler with our component name intercept_handler = InterceptHandler(component_name) - # Make sure the handler uses our ColorFormatter config = load_config() component_config = config.get("components", {}).get( component_name, {"prefix": component_name.upper(), "color": "white"} @@ -284,15 +269,13 @@ def configure_uvicorn_logging(component_name: str) -> None: formatter = ColorFormatter(fmt, component_config, level_colors, use_colors) intercept_handler.setFormatter(formatter) - # Configure Uvicorn loggers to use our handler for logger_name in ["uvicorn", "uvicorn.error", "uvicorn.access"]: uvicorn_logger = logging.getLogger(logger_name) - uvicorn_logger.handlers = [] # Clear existing handlers - uvicorn_logger.propagate = False # Don't propagate to root - uvicorn_logger.setLevel(logging.INFO) # Ensure log level is at least INFO + uvicorn_logger.handlers = [] + uvicorn_logger.propagate = False + uvicorn_logger.setLevel(logging.INFO) uvicorn_logger.addHandler(intercept_handler) - # Also configure asyncio logger similarly asyncio_logger = logging.getLogger("asyncio") asyncio_logger.handlers = [] asyncio_logger.propagate = False diff --git a/backend/app/models/FaceDetector.py b/backend/app/models/FaceDetector.py index 64817743d..c5289c7c7 100644 --- a/backend/app/models/FaceDetector.py +++ b/backend/app/models/FaceDetector.py @@ -1,5 +1,3 @@ -# app/detectors/FaceDetector.py - from typing import Dict, List, Optional, TypedDict import cv2 @@ -16,7 +14,6 @@ ) from app.utils.face_quality import face_passes_quality_gate -# Initialize logger logger = get_logger(__name__) @@ -76,7 +73,6 @@ def detect_faces(self, image_path: str) -> Optional[FaceDetectionResult]: faces_skipped += 1 continue - # Create bounding box dictionary in JSON format bbox = {"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1} bboxes.append(bbox) confidences.append(float(score)) diff --git a/backend/app/models/ONNXSessionBase.py b/backend/app/models/ONNXSessionBase.py index fa3a39db3..118b7dd8d 100644 --- a/backend/app/models/ONNXSessionBase.py +++ b/backend/app/models/ONNXSessionBase.py @@ -34,10 +34,9 @@ def __init__(self, model_path: str): self._session_registered = False self._session: onnxruntime.InferenceSession | None = None self._lock = threading.Lock() - # Serializes session.run() on the shared session. The DirectML EP - # faults (0xC0000005) if two threads run the same session at once, - # which the parallel image+video semantic searches do. Separate from - # _lock so inference never blocks on session creation/close. + # DirectML faults (0xC0000005) if two threads run one session at once, + # which parallel image+video search does. Separate from _lock so inference + # never waits on session creation or close. self._inference_lock = threading.Lock() def _create_session(self) -> onnxruntime.InferenceSession: @@ -71,13 +70,9 @@ def _clear_tensor_names(self) -> None: def close(self) -> None: with self._lock: - # Registration cleanup must not be gated on self._session being - # non-None: get_session() can null self._session on a validation - # failure (e.g. missing expected tensor names) while - # _session_registered stays True from the earlier - # mark_model_session_active() call. If cleanup were gated on - # self._session, that registration would never be released, - # permanently blocking this model from being uninstalled. + # Checks _session_registered too, not just _session: a validation + # failure in get_session() nulls the session while leaving the + # registration behind, and an unreleased one blocks uninstall. was_active = self._session is not None or self._session_registered self._session = None self._clear_tensor_names() diff --git a/backend/app/models/ObjectClassifier.py b/backend/app/models/ObjectClassifier.py index 9a940da83..b35c3fc42 100644 --- a/backend/app/models/ObjectClassifier.py +++ b/backend/app/models/ObjectClassifier.py @@ -22,7 +22,6 @@ def get_classes(self, img_path) -> list[int] | None: _, _, class_ids = self.yolo_classifier(img) logger.debug(f"Class IDs detected: {class_ids}") - # convert class_ids to a list of integers from numpy array class_ids = [int(class_id) for class_id in class_ids] return class_ids diff --git a/backend/app/models/model_registry.py b/backend/app/models/model_registry.py index 79e77907f..f18478e26 100644 --- a/backend/app/models/model_registry.py +++ b/backend/app/models/model_registry.py @@ -107,33 +107,33 @@ class ModelSpec(TypedDict): ), "siglip2_large_vision": ModelSpec( filename="SigLIP2_Large_Vision.onnx", - url="PLACEHOLDER_URL", # TODO - sha256="PLACEHOLDER_SHA256", # TODO - size_mb=0, # TODO + url="PLACEHOLDER_URL", + sha256="PLACEHOLDER_SHA256", + size_mb=0, feature="semantic_vision", tier="medium", ), "siglip2_large_text": ModelSpec( filename="SigLIP2_Large_Text.onnx", - url="PLACEHOLDER_URL", # TODO - sha256="PLACEHOLDER_SHA256", # TODO - size_mb=0, # TODO + url="PLACEHOLDER_URL", + sha256="PLACEHOLDER_SHA256", + size_mb=0, feature="semantic_text", tier="medium", ), "siglip2_so400m_vision": ModelSpec( filename="SigLIP2_SO400M_Vision.onnx", - url="PLACEHOLDER_URL", # TODO - sha256="PLACEHOLDER_SHA256", # TODO - size_mb=0, # TODO + url="PLACEHOLDER_URL", + sha256="PLACEHOLDER_SHA256", + size_mb=0, feature="semantic_vision", tier="manual", ), "siglip2_so400m_text": ModelSpec( filename="SigLIP2_SO400M_Text.onnx", - url="PLACEHOLDER_URL", # TODO - sha256="PLACEHOLDER_SHA256", # TODO - size_mb=0, # TODO + url="PLACEHOLDER_URL", + sha256="PLACEHOLDER_SHA256", + size_mb=0, feature="semantic_text", tier="manual", ), diff --git a/backend/app/routes/face_clusters.py b/backend/app/routes/face_clusters.py index 46cb62506..9f3f84f99 100644 --- a/backend/app/routes/face_clusters.py +++ b/backend/app/routes/face_clusters.py @@ -91,14 +91,12 @@ def rename_cluster( ): """Rename a face cluster by its ID.""" try: - # Step 1: Data Validation if not cluster_id.strip(): raise ValueError("Cluster ID cannot be empty") if not request.cluster_name.strip(): raise ValueError("Cluster name cannot be empty") - # Step 2: Check if cluster exists existing_cluster = db_get_cluster_by_id(cluster_id) if not existing_cluster: raise HTTPException( @@ -110,7 +108,6 @@ def rename_cluster( ).model_dump(), ) - # Step 3: Update cluster name updated = db_update_cluster( cluster_id=cluster_id, cluster_name=request.cluster_name.strip(), @@ -211,7 +208,6 @@ def get_all_clusters(): def get_cluster_images(cluster_id: str): """Get all images that contain faces belonging to a specific cluster.""" try: - # Step 1: Validate cluster exists cluster = db_get_cluster_by_id(cluster_id) if not cluster: raise HTTPException( @@ -223,10 +219,8 @@ def get_cluster_images(cluster_id: str): ).model_dump(), ) - # Step 2: Get images for this cluster images_data = db_get_images_by_cluster_id(cluster_id) - # Step 3: Convert to response models images = [ ImageInCluster( id=img["image_id"], @@ -240,8 +234,8 @@ def get_cluster_images(cluster_id: str): for img in images_data ] - # Step 4: Videos the same person appears in, as the videos routes - # return them, so the frontend renders them with the same card. + # Videos this person appears in, shaped like the videos routes return them so + # the frontend reuses the same card. videos = video_util_to_video_data( db_get_videos_by_ids(db_get_video_ids_by_cluster_id(cluster_id)) ) @@ -323,7 +317,7 @@ def face_tagging( ).model_dump(), ) - MAX_B64_LEN = 14_000_000 # 10MB + MAX_B64_LEN = 14_000_000 # about 10MB once base64-decoded if len(base64_data) > MAX_B64_LEN: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/backend/app/routes/folders.py b/backend/app/routes/folders.py index b0064e979..cb1984bae 100644 --- a/backend/app/routes/folders.py +++ b/backend/app/routes/folders.py @@ -68,7 +68,6 @@ video_util_process_untagged_videos, ) -# Initialize logger logger = get_logger(__name__) router = APIRouter() @@ -124,7 +123,7 @@ def _queue_post_index_tagging_sweep(index_future: Future, app_state: State) -> N logger.error(f"Failed to queue post-index tagging sweep: {e}") -def post_folder_add_sequence(folder_path: str, folder_id: int): +def post_folder_add_sequence(folder_path: str, folder_id: str): """ Post-addition sequence for a folder. This function is called after a folder is successfully added. @@ -132,22 +131,19 @@ def post_folder_add_sequence(folder_path: str, folder_id: int): """ folder_ids_and_paths = [] try: - # Get all folder IDs and paths that match the root path prefix folder_data = [] folder_ids_and_paths = db_get_folder_ids_by_path_prefix(folder_path) - # Set all folders to non-recursive (False) + # recursive=False: the prefix match already lists every subfolder. for folder_id_from_db, folder_path_from_db in folder_ids_and_paths: folder_data.append((folder_path_from_db, folder_id_from_db, False)) db_update_folder_indexing_status(folder_id_from_db, INDEXING_IN_PROGRESS) logger.info(f"Add folder: {folder_data}") - # Process images and videos in all folders image_util_process_folder_images(folder_data) video_util_process_folder_videos(folder_data) - # Restart sync microservice watcher after processing images API_util_restart_sync_microservice_watcher() for folder_id_from_db, _ in folder_ids_and_paths: @@ -215,7 +211,6 @@ def post_sync_folder_sequence( It processes images in the folder and updates the database. """ try: - # Create folder data array folder_data = [] folder_data.append((folder_path, folder_id, False)) @@ -225,7 +220,6 @@ def post_sync_folder_sequence( logger.info(f"Sync folder: {folder_data}") db_set_tagging_completed(False) - # Process images and videos in all folders image_util_process_folder_images(folder_data) video_util_process_folder_videos(folder_data) image_util_process_untagged_images() @@ -240,7 +234,6 @@ def post_sync_folder_sequence( video_util_process_unembedded_frames() semantic_util_score_videos() - # Restart sync microservice watcher after processing images API_util_restart_sync_microservice_watcher() except Exception as e: logger.error( @@ -259,31 +252,26 @@ def post_sync_folder_sequence( ) def add_folder(request: AddFolderRequest, app_state: State = Depends(get_state)): try: - # Step 1: Data Validation if not os.path.isdir(request.folder_path): raise ValueError( f"Error: '{request.folder_path}' is not a valid directory." ) - if ( - not os.access(request.folder_path, os.R_OK) - # Uncomment the following lines if you want to check for write and execute permissions - # or not os.access(request.folder_path, os.W_OK) - # or not os.access(request.folder_path, os.X_OK) - ): + # Traversal as well as read: indexing walks the tree, and a directory + # that is readable but not searchable lists names and descends nowhere. + if not os.access(request.folder_path, os.R_OK | os.X_OK): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorResponse( success=False, error="Permission denied", - message="The app does not have read permission for the specified folder", + message="The app does not have read and traversal permission for the specified folder", ).model_dump(), ) request.folder_path = os.path.abspath(request.folder_path) - # Step 2: Check if folder already exists if db_folder_exists(request.folder_path): raise HTTPException( status_code=status.HTTP_409_CONFLICT, @@ -294,12 +282,10 @@ def add_folder(request: AddFolderRequest, app_state: State = Depends(get_state)) ).model_dump(), ) - # Step 3: If parent_folder_id not provided, try to find it parent_folder_id = request.parent_folder_id if parent_folder_id is None: parent_folder_id = db_find_parent_folder_id(request.folder_path) - # Step 4: Add folder tree to database root_folder_id, folder_map = folder_util_add_folder_tree( root_path=request.folder_path, parent_folder_id=parent_folder_id, @@ -307,19 +293,16 @@ def add_folder(request: AddFolderRequest, app_state: State = Depends(get_state)) taggingCompleted=request.taggingCompleted, ) - # Step 5: Update parent ids for the subtree db_update_parent_ids_for_subtree(request.folder_path, folder_map) - # Step 6: Call the post-addition sequence in a separate process. # Own pool so this never waits on another folder's AI tagging. indexing_executor: ProcessPoolExecutor = app_state.indexing_executor index_future = indexing_executor.submit( post_folder_add_sequence, request.folder_path, root_folder_id ) - # Also queue a catch-up tagging sweep for once indexing lands. Needed - # because indexing and tagging are now on separate pools: if the AI - # pool is free, a sweep can start (and finish, finding nothing) before - # this folder's images are even in the DB - see the callback docstring. + # Indexing and tagging now sit on separate pools, so a sweep on a free + # AI pool can finish before this folder's images reach the DB. Queue a + # catch-up sweep to run once indexing lands. index_future.add_done_callback( lambda future: _queue_post_index_tagging_sweep(future, app_state) ) @@ -509,20 +492,17 @@ def delete_folders( def sync_folder(request: SyncFolderRequest, app_state: State = Depends(get_state)): """Sync a folder by comparing filesystem folders with database entries and removing extra DB entries.""" try: - # Step 1: Get current state from both sources db_child_folders = db_get_direct_child_folders(request.folder_id) filesystem_folders = folder_util_get_filesystem_direct_child_folders( request.folder_path ) - # Step 2: Compare and identify differences filesystem_folder_set = set(filesystem_folders) db_folder_paths = {folder_path for folder_id, folder_path in db_child_folders} folders_to_delete = db_folder_paths - filesystem_folder_set folders_to_add = filesystem_folder_set - db_folder_paths - # Step 3: Perform synchronization operations deleted_count, deleted_folders = folder_util_delete_obsolete_folders( db_child_folders, folders_to_delete ) @@ -530,7 +510,6 @@ def sync_folder(request: SyncFolderRequest, app_state: State = Depends(get_state folders_to_add, request.folder_id ) - # Extract just the paths for the API response added_folders = [ folder_path for folder_id, folder_path in added_folders_with_ids ] @@ -542,7 +521,6 @@ def sync_folder(request: SyncFolderRequest, app_state: State = Depends(get_state request.folder_id, added_folders_with_ids, ) - # Step 4: Return comprehensive response return SyncFolderResponse( data=SyncFolderData( deleted_count=deleted_count, @@ -589,7 +567,6 @@ def get_all_folders(): try: folder_details_raw = db_get_all_folder_details() - # Convert raw tuples to FolderDetails objects folders = [] for folder_data in folder_details_raw: ( diff --git a/backend/app/routes/share.py b/backend/app/routes/share.py index c17322caa..3b0c9c5de 100644 --- a/backend/app/routes/share.py +++ b/backend/app/routes/share.py @@ -44,7 +44,6 @@ def _not_found(error: str, message: str) -> HTTPException: ) -# GET /share/interfaces - Ranked LAN addresses the share could be reached on @router.get( "/interfaces", response_model=GetInterfacesResponse, responses=_SERVER_ERROR ) @@ -64,7 +63,6 @@ def get_interfaces() -> GetInterfacesResponse: ) -# GET /share/ - List every active share @router.get("/", response_model=GetSharesResponse, responses=_SERVER_ERROR) def get_shares() -> GetSharesResponse: port = share_server_port() @@ -78,7 +76,6 @@ def get_shares() -> GetSharesResponse: ) -# POST /share/albums/{album_id} - Start sharing an album over the network @router.post( "/albums/{album_id}", response_model=CreateShareResponse, @@ -118,7 +115,6 @@ async def create_share( ) -# DELETE /share/{token} - Stop sharing @router.delete( "/{token}", response_model=RevokeShareResponse, diff --git a/backend/app/routes/shutdown.py b/backend/app/routes/shutdown.py index 716a79104..5e993f762 100644 --- a/backend/app/routes/shutdown.py +++ b/backend/app/routes/shutdown.py @@ -49,7 +49,6 @@ async def shutdown(): """ logger.info("Shutdown request received for PictoPy backend") - # Define callback to handle potential exceptions in the background task def _handle_shutdown_exception(task: asyncio.Task): try: task.result() diff --git a/backend/app/routes/test.py b/backend/app/routes/test.py deleted file mode 100644 index 5404dab80..000000000 --- a/backend/app/routes/test.py +++ /dev/null @@ -1,173 +0,0 @@ -# import cv2 -# import asyncio -# from fastapi import APIRouter, status, HTTPException -# from app.config.settings import DEFAULT_FACE_DETECTION_MODEL, IMAGES_PATH -# from app.yolov8 import YOLOv8 -# from app.yolov8.utils import class_names -# from app.utils.classification import get_classes -# from app.utils.wrappers import exception_handler_wrapper -# from app.schemas.test import ( -# TestRouteRequest, -# TestRouteResponse, -# DetectionData, -# ErrorResponse, -# AddSingleImageRequest, -# AddSingleImageResponse, -# TestImageResponse, -# GetImagesResponse, -# ) -# from app.database.images import get_all_images_from_folder_id -# from app.database.folders import db_get_all_folder_ids -# import os -# import shutil - - -# router = APIRouter() - - -# async def run_get_classes(img_path): -# loop = asyncio.get_event_loop() -# await loop.run_in_executor(None, get_classes, img_path) - - -# @router.post( -# "/return", -# response_model=TestRouteResponse, -# responses={code: {"model": ErrorResponse} for code in [400, 500]}, -# ) -# async def test_route(payload: TestRouteRequest): -# try: -# model_path = DEFAULT_FACE_DETECTION_MODEL -# yolov8_detector = YOLOv8(model_path, conf_thres=0.2, iou_thres=0.3) - -# img_path = payload.path - -# img = cv2.imread(img_path) - -# if img is None: -# raise HTTPException( -# status_code=status.HTTP_400_BAD_REQUEST, -# detail=ErrorResponse( -# success=False, -# message=f"Failed to load image: {img_path}", -# error="Failed to load image", -# ).model_dump(), -# ) - -# boxes, scores, class_ids = yolov8_detector(img) -# print(scores, "\n", class_ids) -# detected_classes = [class_names[x] for x in class_ids] - -# asyncio.create_task(run_get_classes(img_path)) - -# return TestRouteResponse( -# success=True, -# message="Object detection completed successfully", -# data=DetectionData(class_ids=class_ids, detected_classes=detected_classes), -# ) - -# except Exception as e: -# raise HTTPException( -# status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, -# detail=ErrorResponse(success=False, error="Internal server error", message=str(e)).model_dump(), -# ) - - -# @router.get( -# "/images", -# response_model=GetImagesResponse, -# responses={code: {"model": ErrorResponse} for code in [500]}, -# ) -# @exception_handler_wrapper -# def get_images(): -# try: -# files = os.listdir(IMAGES_PATH) -# image_extensions = [".jpg", ".jpeg", ".png", ".bmp", ".gif"] -# image_files = [ -# os.path.abspath(os.path.join(IMAGES_PATH, file)) -# for file in files -# if os.path.splitext(file)[1].lower() in image_extensions -# ] - -# return GetImagesResponse( -# success=True, -# message="Successfully retrieved all images", -# data={"images": image_files}, -# ) - -# except Exception as e: -# raise HTTPException( -# status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, -# detail=ErrorResponse(success=False, error="Internal server error", message=str(e)).model_dump(), -# ) - - -# @router.post( -# "/single-image", -# response_model=AddSingleImageResponse, -# responses={code: {"model": ErrorResponse} for code in [400, 500]}, -# ) -# @exception_handler_wrapper -# def add_single_image(payload: AddSingleImageRequest): -# try: -# image_path = payload.path -# if not os.path.isfile(image_path): -# raise HTTPException( -# status_code=status.HTTP_400_BAD_REQUEST, -# detail=ErrorResponse( -# success=False, -# error="Invalid file path", -# message="The provided path is not a valid file", -# ).model_dump(), -# ) - -# image_extensions = [".jpg", ".jpeg", ".png", ".bmp", ".gif"] -# file_extension = os.path.splitext(image_path)[1].lower() -# if file_extension not in image_extensions: -# raise HTTPException( -# status_code=status.HTTP_400_BAD_REQUEST, -# detail=ErrorResponse( -# success=False, -# error="Invalid file type", -# message="The file is not a supported image type", -# ), -# ) - -# destination_path = os.path.join(IMAGES_PATH, os.path.basename(image_path)) -# shutil.copy(image_path, destination_path) - -# return ( -# AddSingleImageResponse( -# success=True, -# message="Image copied to the gallery successfully", -# data={"destination_path": destination_path}, -# ), -# ) -# except Exception as e: -# raise HTTPException( -# status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, -# detail=ErrorResponse(success=False, message="Internal server error", error=str(e)).model_dump(), -# ) - - -# @router.get( -# "/test-image", -# response_model=TestImageResponse, -# responses={code: {"model": ErrorResponse} for code in [400]}, -# ) -# def test_images(): -# try: -# folder_ids = db_get_all_folder_ids() -# for folder_id in folder_ids: -# print("Current Folder ID = ", folder_id) -# image_paths = get_all_images_from_folder_id(folder_id) -# print("Image Paths = ", image_paths) - -# print("Folder IDS = ", folder_ids) -# return TestImageResponse(success=True, message="Success") - -# except Exception as e: -# raise HTTPException( -# status_code=status.HTTP_400_BAD_REQUEST, -# detail=ErrorResponse(success=False, message="Internal server error", error=str(e)).model_dump(), -# ) diff --git a/backend/app/routes/videos.py b/backend/app/routes/videos.py index 923eae755..784891827 100644 --- a/backend/app/routes/videos.py +++ b/backend/app/routes/videos.py @@ -22,12 +22,10 @@ ) from app.logging.setup_logging import get_logger -# Initialize logger logger = get_logger(__name__) router = APIRouter() -# Response Models class GetAllVideosResponse(BaseModel): success: bool message: str @@ -434,7 +432,6 @@ def toggle_favourite(req: ToggleFavouriteRequest): message=f"No video with id '{video_id}' could be toggled", ).model_dump(), ) - # Fetch updated status to return video = db_get_video_by_id(video_id) if not video: raise HTTPException( diff --git a/backend/app/schemas/album.py b/backend/app/schemas/album.py index 9a6c7b471..118df619e 100644 --- a/backend/app/schemas/album.py +++ b/backend/app/schemas/album.py @@ -16,11 +16,6 @@ class Album(BaseModel): updated_at: Optional[str] = None -# ############################## -# Request Handler -# ############################## - - class CreateAlbumRequest(BaseModel): name: str = Field(..., min_length=1) description: Optional[str] = "" @@ -88,11 +83,6 @@ def validate_image_ids(cls, value: List[str]) -> List[str]: return cleaned -# ############################## -# Response Handler -# ############################## - - class GetAlbumsResponse(BaseModel): success: bool albums: List[Album] diff --git a/backend/app/schemas/face_clusters.py b/backend/app/schemas/face_clusters.py index acd342be3..a3471243e 100644 --- a/backend/app/schemas/face_clusters.py +++ b/backend/app/schemas/face_clusters.py @@ -4,12 +4,10 @@ from app.schemas.videos import VideoData -# Request Models class RenameClusterRequest(BaseModel): cluster_name: str -# Response Models class RenameClusterData(BaseModel): cluster_id: str cluster_name: str diff --git a/backend/app/schemas/facetagging.py b/backend/app/schemas/facetagging.py index bd2544a04..cc179e9e8 100644 --- a/backend/app/schemas/facetagging.py +++ b/backend/app/schemas/facetagging.py @@ -2,7 +2,6 @@ from typing import List, Dict -# Response Model class SimilarPair(BaseModel): image1: str image2: str diff --git a/backend/app/schemas/folders.py b/backend/app/schemas/folders.py index 07fac02ac..f46ba0a98 100644 --- a/backend/app/schemas/folders.py +++ b/backend/app/schemas/folders.py @@ -2,7 +2,6 @@ from typing import Optional, List -# Request Models class AddFolderRequest(BaseModel): folder_path: str parent_folder_id: Optional[str] = None # UUID as string @@ -10,15 +9,15 @@ class AddFolderRequest(BaseModel): class UpdateAITaggingRequest(BaseModel): - folder_ids: List[str] # List of folder UUIDs + folder_ids: List[str] class DeleteFoldersRequest(BaseModel): - folder_ids: List[str] # List of folder UUIDs to delete + folder_ids: List[str] class SyncFolderRequest(BaseModel): - folder_path: str # Path of the folder to sync + folder_path: str folder_id: str # UUID of the folder to sync @@ -32,9 +31,7 @@ class FolderDetails(BaseModel): taggingCompleted: Optional[bool] = None image_count: int = 0 video_count: int = 0 - indexing_status: Optional[str] = ( - "completed" # Default to "completed" if not provided - ) + indexing_status: Optional[str] = "completed" class GetAllFoldersData(BaseModel): @@ -59,14 +56,13 @@ class DeleteFoldersData(BaseModel): class SyncFolderData(BaseModel): deleted_count: int - deleted_folders: List[str] # List of folder paths that were deleted + deleted_folders: List[str] added_count: int - added_folders: List[str] # List of folder paths that were added + added_folders: List[str] folder_id: str folder_path: str -# Response Models class GetAllFoldersResponse(BaseModel): success: bool message: Optional[str] = None diff --git a/backend/app/schemas/images.py b/backend/app/schemas/images.py index 934042508..7df5910e7 100644 --- a/backend/app/schemas/images.py +++ b/backend/app/schemas/images.py @@ -40,7 +40,6 @@ class DeleteThumbnailsRequest(BaseModel): folder_path: str -# Response Model class FaceTaggingResponse(BaseModel): success: bool message: str diff --git a/backend/app/schemas/share.py b/backend/app/schemas/share.py index 651e833fc..585ac21c2 100644 --- a/backend/app/schemas/share.py +++ b/backend/app/schemas/share.py @@ -4,10 +4,6 @@ from app.share.registry import PASSWORD_MAX_BYTES -# ############################## -# Request Handler -# ############################## - class CreateShareRequest(BaseModel): # None means the share lives until it is revoked or PictoPy closes. @@ -25,11 +21,6 @@ def check_password_length(cls, value: Optional[str]) -> Optional[str]: return value -# ############################## -# Response Handler -# ############################## - - class ShareInterface(BaseModel): """One address the share could be advertised on, best guess first.""" diff --git a/backend/app/utils/ONNX.py b/backend/app/utils/ONNX.py index ce5c87dd8..ebe9c82fa 100644 --- a/backend/app/utils/ONNX.py +++ b/backend/app/utils/ONNX.py @@ -16,18 +16,15 @@ def ONNX_util_get_execution_providers(exclude: tuple[str, ...] = ()) -> list: """ from app.database.metadata import db_get_metadata - # Get metadata from database metadata = db_get_metadata() - # Default to CPU if no preferences found + # GPU acceleration stays on unless the user turned it off. gpu_acceleration = True - # Extract GPU acceleration setting from user preferences if metadata and "user_preferences" in metadata: user_prefs = metadata["user_preferences"] gpu_acceleration = user_prefs.get("GPU_Acceleration", True) - # Return appropriate execution providers if gpu_acceleration: providers = [ p for p in onnxruntime.get_available_providers() if p not in exclude diff --git a/backend/app/utils/SigLIP.py b/backend/app/utils/SigLIP.py index 355378599..1dab502ca 100644 --- a/backend/app/utils/SigLIP.py +++ b/backend/app/utils/SigLIP.py @@ -13,23 +13,18 @@ def siglip_util_preprocess_image(img_path: str, resolution: int) -> np.ndarray | Returns a [3, R, R] float32 array or None if the image is corrupt/unreadable. """ try: - # PIL bicubic (antialiased, Pillow>=9.1). Measured vs HF - # SiglipImageProcessor on real 4032x3024 photos: embedding cosine - # ~0.984 (HF uses an internal resampler that plain PIL resize does - # not reproduce; exact parity would require shipping transformers). - # Production is self-consistent: SIGLIP2_MATCH_THRESHOLD was tuned - # against THIS pipeline. Any future threshold/calibration work must - # use this function, not AutoImageProcessor. + # PIL bicubic, antialiased since Pillow 9.1, measures cosine ~0.984 against + # HF's SiglipImageProcessor; parity would mean shipping transformers. + # SIGLIP2_MATCH_THRESHOLD was tuned here, so recalibrate with this, not HF's. img = ( Image.open(img_path) .convert("RGB") .resize((resolution, resolution), Image.BICUBIC) ) - # Convert to numpy array and normalize to [0, 1] img_np = np.asarray(img).astype(np.float32) / 255.0 - # Normalize: (x - 0.5) / 0.5 (SigLIP mean=std=0.5 per channel) + # SigLIP normalizes with mean = std = 0.5 per channel. img_np = (img_np - 0.5) / 0.5 # Transpose HWC -> CHW @@ -60,11 +55,9 @@ def siglip_util_tokenize_query(query: str) -> tuple[np.ndarray, np.ndarray]: current_key = get_siglip2_tokenizer_key(SIGLIP2_ACTIVE_CHECKPOINT) - # Lock around the check-load-assign sequence only: without it, two - # threads racing on a cold/stale cache could both load a tokenizer and - # interleave their _tokenizer/_tokenizer_key assignments, leaving the - # pair mismatched. encode() itself doesn't touch the cache, so it runs - # outside the lock on a captured local -- no need to serialize it. + # Guards check-load-assign only: racing threads could otherwise interleave + # the _tokenizer/_tokenizer_key writes and leave the pair mismatched. + # encode() never touches the cache, so it runs outside on a local. with _tokenizer_lock: if _tokenizer is None or _tokenizer_key != current_key: tokenizer_path = get_model_path(current_key) @@ -85,9 +78,8 @@ def siglip_util_tokenize_query(query: str) -> tuple[np.ndarray, np.ndarray]: 1, SIGLIP2_TEXT_MAX_LENGTH ) - # attention_mask: np.ones, NOT encoding's mask. - # SigLIP trains fixed-length without pad masking; ONNX export validated with all-ones mask. - # Encoding's real mask (zeros on padding) would deviate from validated numbers. + # All ones, not the encoding's mask: SigLIP trains fixed-length without pad + # masking, and the ONNX export was validated against an all-ones mask. attention_mask = np.ones((1, SIGLIP2_TEXT_MAX_LENGTH), dtype=np.int64) return input_ids, attention_mask diff --git a/backend/app/utils/YOLO.py b/backend/app/utils/YOLO.py index cf63d41dc..20640bf1e 100644 --- a/backend/app/utils/YOLO.py +++ b/backend/app/utils/YOLO.py @@ -85,28 +85,24 @@ "toothbrush", ] -# Create a list of colors for each class where each color is a tuple of 3 integer values +# Fixed seed so each class keeps the same box color between runs. rng = np.random.default_rng(3) colors = rng.uniform(0, 255, size=(len(class_names), 3)) def YOLO_util_nms(boxes, scores, iou_threshold): - # Sort by score sorted_indices = np.argsort(scores)[::-1] keep_boxes = [] while sorted_indices.size > 0: - # Pick the last box + # Keep the best remaining box and drop any that overlap it past iou_threshold. box_id = sorted_indices[0] keep_boxes.append(box_id) - # Compute IoU of the picked box with the rest ious = YOLO_util_compute_iou(boxes[box_id, :], boxes[sorted_indices[1:], :]) - # Remove boxes with IoU over the threshold keep_indices = np.where(ious < iou_threshold)[0] - # print(keep_indices.shape, sorted_indices.shape) sorted_indices = sorted_indices[keep_indices + 1] return keep_boxes @@ -128,28 +124,24 @@ def YOLO_util_multiclass_nms(boxes, scores, class_ids, iou_threshold): def YOLO_util_compute_iou(box, boxes): - # Compute xmin, ymin, xmax, ymax for both boxes xmin = np.maximum(box[0], boxes[:, 0]) ymin = np.maximum(box[1], boxes[:, 1]) xmax = np.minimum(box[2], boxes[:, 2]) ymax = np.minimum(box[3], boxes[:, 3]) - # Compute intersection area intersection_area = np.maximum(0, xmax - xmin) * np.maximum(0, ymax - ymin) - # Compute union area box_area = (box[2] - box[0]) * (box[3] - box[1]) boxes_area = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) union_area = box_area + boxes_area - intersection_area - # Compute IoU iou = intersection_area / union_area return iou def YOLO_util_xywh2xyxy(x): - # Convert bounding box (x, y, w, h) to bounding box (x1, y1, x2, y2) + # x, y is the box center. y = np.copy(x) y[..., 0] = x[..., 0] - x[..., 2] / 2 y[..., 1] = x[..., 1] - x[..., 3] / 2 @@ -169,7 +161,6 @@ def YOLO_util_draw_detections( det_img = YOLO_util_draw_masks(det_img, boxes, class_ids, mask_alpha) - # Draw bounding boxes and labels of detections for class_id, box, score in zip(class_ids, boxes, scores): if score < confidence_threshold or class_id >= len(class_names) - 1: color = colors[-1] @@ -230,13 +221,12 @@ def YOLO_util_draw_masks( ) -> np.ndarray: mask_img = image.copy() - # Draw bounding boxes and labels of detections for box, class_id in zip(boxes, classes): color = colors[class_id] x1, y1, x2, y2 = box.astype(int) - # Draw fill rectangle in mask image + # Thickness -1 fills the rectangle. cv2.rectangle(mask_img, (x1, y1), (x2, y2), color, -1) return cv2.addWeighted(mask_img, mask_alpha, image, 1 - mask_alpha, 0) @@ -254,18 +244,15 @@ def YOLO_util_get_model_path(model_type: str = "object") -> str: """ from app.database.metadata import db_get_metadata - # Get metadata from database metadata = db_get_metadata() # Default model size if no preferences found model_size = "small" - # Extract YOLO model size from user preferences if metadata and "user_preferences" in metadata: user_prefs = metadata["user_preferences"] model_size = user_prefs.get("YOLO_model_size", "small") - # Define model mappings model_mappings = { "object": { "nano": settings.NANO_OBJ_DETECTION_MODEL, @@ -279,8 +266,6 @@ def YOLO_util_get_model_path(model_type: str = "object") -> str: }, } - # Get the appropriate model mapping models = model_mappings.get(model_type, model_mappings["object"]) - # Return the model path with fallback to small return models.get(model_size, models["small"]) diff --git a/backend/app/utils/face_quality.py b/backend/app/utils/face_quality.py index 4b3c082dc..785d30e3a 100644 --- a/backend/app/utils/face_quality.py +++ b/backend/app/utils/face_quality.py @@ -14,17 +14,15 @@ def face_passes_quality_gate( Evaluates a detected face against quality thresholds before it proceeds to embedding. All checks must pass for the face to be considered valid. """ - # 1. Completeness check (Confidence) if conf_score < conf_threshold: return False - # 2. Size check x1, y1, x2, y2 = bbox area = (x2 - x1) * (y2 - y1) if area < min_face_size: return False - # 3. Blur check + # Blur: a soft crop has low Laplacian variance. if face_crop.size == 0: return False diff --git a/backend/app/utils/hardware_detect.py b/backend/app/utils/hardware_detect.py index e4a33cc5e..d54e05b8b 100644 --- a/backend/app/utils/hardware_detect.py +++ b/backend/app/utils/hardware_detect.py @@ -92,7 +92,6 @@ def detect_hardware_tier() -> str: if apple_tier is not None: return apple_tier - # Check RAM in GB ram_gb = psutil.virtual_memory().total / (1024**3) gpu_names = detect_physical_gpu() diff --git a/backend/app/utils/image_metadata.py b/backend/app/utils/image_metadata.py index c5a91d3e6..66599bded 100644 --- a/backend/app/utils/image_metadata.py +++ b/backend/app/utils/image_metadata.py @@ -11,14 +11,12 @@ def extract_metadata(image_path): metadata = {} - # Check if file exists if not os.path.exists(image_path): raise FileNotFoundError(f"File not found: {image_path}") try: with Image.open(image_path) as image: try: - # Basic image info info_dict = { "image_size": image.size, "image_format": image.format, @@ -26,7 +24,6 @@ def extract_metadata(image_path): } metadata.update(info_dict) - # Extract EXIF data exifdata = image.getexif() for tag_id in exifdata: tag = TAGS.get(tag_id, tag_id) @@ -59,7 +56,6 @@ def extract_metadata(image_path): except Exception as e: raise RuntimeError(f"Unexpected error processing {image_path}: {e}") - # File size extraction try: metadata["file_size"] = os.path.getsize(image_path) except OSError as file_error: @@ -67,7 +63,6 @@ def extract_metadata(image_path): f"Could not retrieve file size for {image_path}. Error: {file_error}" ) - # Image creation date try: creation_time = os.path.getctime(image_path) metadata["creation_date"] = datetime.fromtimestamp(creation_time).strftime( diff --git a/backend/app/utils/memory_monitor.py b/backend/app/utils/memory_monitor.py index c60c4a159..560db207e 100644 --- a/backend/app/utils/memory_monitor.py +++ b/backend/app/utils/memory_monitor.py @@ -22,18 +22,14 @@ def log_memory_usage(func: Callable) -> Callable: def wrapper(*args, **kwargs): process = psutil.Process() - # Memory before execution mem_before = process.memory_info().rss / 1024 / 1024 # MB start_time = time.time() - # Execute function result = func(*args, **kwargs) - # Memory after execution mem_after = process.memory_info().rss / 1024 / 1024 # MB end_time = time.time() - # Log memory usage logger.info( f"Memory usage for {func.__name__}:\n Before: {mem_before:.2f}MB\n After: {mem_after:.2f}MB\n Difference: {mem_after - mem_before:.2f}MB\n Execution time: {(end_time - start_time) * 1000:.2f}ms" ) diff --git a/backend/app/utils/memory_scoring.py b/backend/app/utils/memory_scoring.py index 03f3018cd..66c394d1a 100644 --- a/backend/app/utils/memory_scoring.py +++ b/backend/app/utils/memory_scoring.py @@ -36,10 +36,8 @@ HOME_CELL_PRECISION = 1 MIN_IMAGES_FOR_HOME = 20 -# Near-duplicate rule. BOTH must hold: visual similarity alone would collapse -# a daily-coffee photo, a revisited viewpoint, or an annual anniversary shot -# into a single survivor. Those are near-identical and genuinely distinct. -# Temporal proximity is necessary, never sufficient. +# A near-duplicate needs BOTH: visual similarity alone would collapse a daily +# coffee or a revisited viewpoint into one survivor, though those are distinct. DUP_COSINE = 0.90 DUP_WINDOW_SECONDS = 120.0 @@ -58,10 +56,8 @@ # renormalized out when their source is missing. ALWAYS_AVAILABLE = frozenset({"favourite", "known_people", "face_presence", "in_album"}) -# Nothing detects faces in a video and a video cannot be put in an album, so -# for videos those are absent, not zero. Scoring a video as face-free and -# album-less would rank every one of them below every photo — the same -# mistake availability renormalization exists to prevent. +# For videos these are absent, not zero: nothing detects faces in one and it +# cannot join an album. Scoring them zero penalizes every video for the gap. UNAVAILABLE_FOR_VIDEO = frozenset({"known_people", "face_presence", "in_album"}) diff --git a/backend/app/utils/model_downloader.py b/backend/app/utils/model_downloader.py index 7437cf61e..760d844ef 100644 --- a/backend/app/utils/model_downloader.py +++ b/backend/app/utils/model_downloader.py @@ -127,7 +127,6 @@ async def ensure_model( ) progress_callback(percent, downloaded, total_size) - # Post-download verification if await verify_sha256(model_path, expected_sha256): logger.info(f"Model {model_key} successfully downloaded and verified.") return model_path diff --git a/backend/app/utils/network.py b/backend/app/utils/network.py index a31f126b5..a9f645385 100644 --- a/backend/app/utils/network.py +++ b/backend/app/utils/network.py @@ -96,10 +96,9 @@ def network_util_list_candidates() -> List[InterfaceCandidate]: if addr.family != socket.AF_INET: continue ip = addr.address - # Loopback is the only address no other device can ever reach. - # A 169.254 address usually means DHCP never answered, but two - # devices on the same link can still reach each other that way, so - # it is ranked last rather than dropped. + # Loopback is the only address no other device can ever reach. 169.254 + # usually means DHCP never answered, but still works link-local -- ranked + # last rather than dropped. if ip.startswith("127."): continue candidates.append( diff --git a/backend/main.py b/backend/main.py index 00c0f63d0..45f9da8a1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -63,10 +63,8 @@ get_logger, ) -# Set up standard logging first setup_logging("backend") -# Configure Uvicorn logging to use our custom formatter configure_uvicorn_logging("backend") path = os.path.dirname(DATABASE_PATH) @@ -77,7 +75,6 @@ @asynccontextmanager async def lifespan(app: FastAPI): - # Create tables and initialize systems generate_openapi_json() db_create_folders_table() db_create_images_table() @@ -103,7 +100,7 @@ async def lifespan(app: FastAPI): # Needs the mappings table (created above): semantic labels register # there as class_ids >= SEMANTIC_CLASS_ID_OFFSET semantic_util_sync_vocabulary() - # New pool, just for folder indexing, so it never queues behind AI tagging + # Its own pool, so folder indexing never queues behind AI tagging. app.state.indexing_executor = ProcessPoolExecutor(max_workers=INDEXING_MAX_WORKERS) # Create ProcessPoolExecutor and attach it to app.state app.state.executor = ProcessPoolExecutor(max_workers=1) @@ -137,7 +134,6 @@ async def lifespan(app: FastAPI): app.state.executor.shutdown(wait=True) -# Create FastAPI app app = FastAPI( lifespan=lifespan, title="PictoPy", @@ -152,7 +148,6 @@ async def lifespan(app: FastAPI): ) -# Initialize logger for this module logger = get_logger(__name__) @@ -182,17 +177,15 @@ def generate_openapi_json(): logger.error(f"Failed to generate openapi.json: {e}") -# Add CORS middleware app.add_middleware( CORSMiddleware, - allow_origins=["*"], # Allows all origins + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -# Basic health check endpoint @app.get("/health", tags=["Health"]) async def root(): return {"message": "PictoPy Server is up and running!"} @@ -218,7 +211,6 @@ async def root(): ) -# Entry point for running with: python3 main.py if __name__ == "__main__": multiprocessing.freeze_support() # Required for Windows logger = get_logger(__name__) @@ -229,7 +221,7 @@ async def root(): host="localhost", port=52123, log_level="info", - log_config=None, # This is crucial - disable Uvicorn's default logging config + log_config=None, # Uvicorn's default config would replace ours ) server = Server(config) server.run() diff --git a/backend/scripts/reset_database.py b/backend/scripts/reset_database.py index 9d257acca..78e8ab421 100644 --- a/backend/scripts/reset_database.py +++ b/backend/scripts/reset_database.py @@ -8,7 +8,6 @@ def delete_db_files(): - # 1. Target the configured database path and its SQLite helper files db_files = [ DATABASE_PATH, DATABASE_PATH + "-journal", @@ -16,7 +15,6 @@ def delete_db_files(): DATABASE_PATH + "-shm", ] - # 2. Target explicitly supported test database files and their helper files test_db_files = [ "test_db.sqlite3", "test_db.sqlite3-journal", @@ -25,7 +23,6 @@ def delete_db_files(): ] db_files.extend(test_db_files) - # Normalize paths and remove duplicates unique_db_files = sorted({os.path.abspath(f) for f in db_files}) deleted_any = False diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py index 23ade1c10..e69de29bb 100644 --- a/backend/tests/__init__.py +++ b/backend/tests/__init__.py @@ -1 +0,0 @@ -# Empty __init__.py to make the tests directory a Python package diff --git a/backend/tests/test_albums.py b/backend/tests/test_albums.py index 10ff4bc23..c439186da 100644 --- a/backend/tests/test_albums.py +++ b/backend/tests/test_albums.py @@ -19,10 +19,6 @@ client = TestClient(app) -# ############################## -# Pytest Fixtures -# ############################## - def album_row( album: dict[str, Any], @@ -88,11 +84,6 @@ def mock_db_locked_album(): } -# ############################## -# Test Classes -# ############################## - - class TestAlbumRoutes: """Test suite for the main album CRUD routes.""" @@ -128,7 +119,6 @@ def test_create_album_variants(self, album_data): assert "album_id" in json_response mock_insert.assert_called_once() - # Verify that the album_id is a valid UUID album_id = json_response["album_id"] uuid.UUID(album_id) # This will raise ValueError if not a valid UUID diff --git a/backend/tests/test_connection.py b/backend/tests/test_connection.py index 1b1b22f53..64cd641e3 100644 --- a/backend/tests/test_connection.py +++ b/backend/tests/test_connection.py @@ -7,10 +7,6 @@ from app.database.connection import get_db_connection -# ############################## -# Pytest Fixtures -# ############################## - @pytest.fixture(scope="function") def test_db(monkeypatch: pytest.MonkeyPatch) -> Iterator[str]: @@ -35,11 +31,6 @@ def read_names(db_path: str) -> List[Tuple[str]]: conn.close() -# ############################## -# Transaction handling -# ############################## - - class TestGetDbConnection: def test_commits_on_success(self, test_db): with get_db_connection() as conn: @@ -69,9 +60,7 @@ def test_closes_the_connection_on_the_way_out(self, test_db): conn.execute("SELECT 1") -# ############################## # Pragmas -# ############################## class TestConnectionPragmas: diff --git a/backend/tests/test_embedding_pipeline.py b/backend/tests/test_embedding_pipeline.py index 53def1edb..02e03e9e0 100644 --- a/backend/tests/test_embedding_pipeline.py +++ b/backend/tests/test_embedding_pipeline.py @@ -107,10 +107,8 @@ def preprocess_side_effect(path, resolution): assert {row[0] for row in upserted_rows} == {"img0", "img2"} assert all(row[1] == "siglip2-base-patch16-224" for row in upserted_rows) - # Only the successfully-preprocessed images are marked embedded. The - # corrupt one stays isEmbedded=False so it's retried on a later pass - # instead of being permanently excluded from semantic search if the - # underlying issue (a transient lock, a restored backup) resolves. + # The corrupt one stays isEmbedded=False so a later pass retries it rather + # than excluding it from search for good. mock_mark_embedded.assert_called_once() assert set(mock_mark_embedded.call_args[0][0]) == {"img0", "img2"} diff --git a/backend/tests/test_extract_location_metadata.py b/backend/tests/test_extract_location_metadata.py index e77ceb5e1..8580b27de 100644 --- a/backend/tests/test_extract_location_metadata.py +++ b/backend/tests/test_extract_location_metadata.py @@ -11,11 +11,6 @@ def extractor(): return MetadataExtractor() -# ############################## -# Test Classes -# ############################## - - LATITUDE_LIMIT = 90.0 LONGITUDE_LIMIT = 180.0 diff --git a/backend/tests/test_face_quality.py b/backend/tests/test_face_quality.py index 005c7cefd..21a01bf96 100644 --- a/backend/tests/test_face_quality.py +++ b/backend/tests/test_face_quality.py @@ -2,10 +2,6 @@ from app.utils.face_quality import face_passes_quality_gate -# ############################## -# Helpers -# ############################## - def sharp_gray(size: int = 50) -> np.ndarray: """A deterministic high-variance (sharp) grayscale image.""" @@ -14,11 +10,6 @@ def sharp_gray(size: int = 50) -> np.ndarray: return img -# ############################## -# Quality gate -# ############################## - - class TestFaceQualityGate: def test_passes_all_gates(self): crop = np.stack([sharp_gray()] * 3, axis=-1) # 3-channel colour crop diff --git a/backend/tests/test_folders.py b/backend/tests/test_folders.py index 5ac73bb5b..274a9269c 100644 --- a/backend/tests/test_folders.py +++ b/backend/tests/test_folders.py @@ -40,10 +40,6 @@ from app.database.videos import db_create_videos_table from app.database.yolo_mapping import db_create_YOLO_classes_table -# ############################## -# Pytest Fixtures -# ############################## - @pytest.fixture(scope="function") def test_db(monkeypatch: pytest.MonkeyPatch) -> Iterator[str]: @@ -57,10 +53,9 @@ def test_db(monkeypatch: pytest.MonkeyPatch) -> Iterator[str]: monkeypatch.setattr("app.database.videos.DATABASE_PATH", db_path) monkeypatch.setattr("app.database.yolo_mapping.DATABASE_PATH", db_path) - # Build the real schema rather than a hand-written copy: a divergent - # CREATE silently reorders columns and drops the ON DELETE CASCADE. - # db_delete_folder turns foreign keys on, so the whole FK chain - # (folders <- images <- image_classes -> mappings) has to resolve. + # The real schema, not a hand-written copy: a divergent CREATE reorders + # columns and drops ON DELETE CASCADE, and the FK chain (folders <- images + # <- image_classes -> mappings) must resolve. db_create_YOLO_classes_table() db_create_folders_table() db_create_images_table() # db_get_all_folder_details LEFT JOINs it @@ -104,7 +99,6 @@ def app_with_state(test_db): app = FastAPI() app.include_router(folders_router, prefix="/folders") - # Mock the executor state app.state.executor = MagicMock(spec=ProcessPoolExecutor) app.state.indexing_executor = MagicMock(spec=ProcessPoolExecutor) @@ -156,17 +150,10 @@ def sample_folder_details(): ] -# ############################## -# Test Classes -# ############################## - - class TestFoldersAPI: """Test class for Folders API endpoints.""" - # ============================================================================ # POST /folders/add-folder - Add Folder Tests - # ============================================================================ @patch("app.routes.folders.folder_util_add_folder_tree") @patch("app.routes.folders.db_update_parent_ids_for_subtree") @@ -203,7 +190,6 @@ def test_add_folder_success( assert data["data"]["folder_id"] == "test-folder-id-123" assert data["data"]["folder_path"] == folder_path - # Verify mocks were called correctly mock_folder_exists.assert_called_once_with(folder_path) mock_add_folder_tree.assert_called_once() @@ -243,24 +229,30 @@ def test_add_folder_invalid_path(self, client): assert data["detail"]["success"] is False assert data["detail"]["error"] == "Validation Error" - # @patch('app.routes.folders.os.access') - # def test_add_folder_permission_denied(self, mock_access, client, temp_folder_structure): - # """Test adding folder without read permissions.""" - # mock_access.return_value = False # Simulate no read permission + @patch("app.routes.folders.os.access") + def test_add_folder_permission_denied( + self, mock_access, client, temp_folder_structure + ): + """A folder the process cannot read and traverse is rejected with 401.""" + mock_access.return_value = False + + folder_path = temp_folder_structure["photos"] + request_data = { + "folder_path": folder_path, + "parent_folder_id": None, + "taggingCompleted": False, + } - # folder_path = temp_folder_structure["photos"] - # request_data = { - # "folder_path": folder_path, - # "parent_folder_id": None, - # "taggingCompleted": False - # } + response = client.post("/folders/add-folder", json=request_data) - # response = client.post("/folders/add-folder", json=request_data) + assert response.status_code == 401 + data = response.json() + assert data["detail"]["success"] is False + assert data["detail"]["error"] == "Permission denied" - # assert response.status_code == 401 - # data = response.json() - # assert data["detail"]["success"] is False - # assert data["detail"]["error"] == "Permission denied" + # The mask matters: os.walk needs X_OK, so R_OK alone would admit a + # readable-but-unsearchable folder and index nothing under it. + mock_access.assert_called_once_with(folder_path, os.R_OK | os.X_OK) @patch("app.routes.folders.folder_util_add_folder_tree") @patch("app.routes.folders.db_update_parent_ids_for_subtree") @@ -298,7 +290,7 @@ def test_add_folder_with_parent_id( assert data["success"] is True assert data["data"]["folder_id"] == "child-folder-id" - # Verify parent lookup was not called since parent_id was provided + # parent_id was provided, so no lookup is needed. mock_find_parent.assert_not_called() @patch("app.routes.folders.folder_util_add_folder_tree") @@ -330,7 +322,7 @@ def test_add_folder_missing_required_field(self, client): response = client.post("/folders/add-folder", json=request_data) - assert response.status_code == 422 # Validation error + assert response.status_code == 422 @patch("app.routes.folders.folder_util_add_folder_tree") @patch("app.routes.folders.db_update_parent_ids_for_subtree") @@ -363,8 +355,7 @@ def test_add_folder_background_processing_called( assert response.status_code == 200 - # Indexing pool used, AI/misc pool untouched - # Access the app state through the client + # Indexing pool used, AI/misc pool untouched. app_state = client.app.state app_state.indexing_executor.submit.assert_called_once() app_state.executor.submit.assert_not_called() @@ -410,9 +401,7 @@ def test_add_folder_queues_tagging_sweep_once_indexing_completes( index_future.set_result(True) app_state.executor.submit.assert_called_once() - # ============================================================================ # POST /folders/enable-ai-tagging - Enable AI Tagging Tests - # ============================================================================ @patch("app.routes.folders.db_enable_ai_tagging_batch") def test_enable_ai_tagging_success(self, mock_enable_batch, client): @@ -448,7 +437,7 @@ def test_enable_ai_tagging_single_folder(self, mock_enable_batch, client): def test_enable_ai_tagging_empty_list(self, client): """Test enabling AI tagging with empty folder_ids list.""" - request_data = {"folder_ids": []} + request_data: dict[str, list[str]] = {"folder_ids": []} response = client.post("/folders/enable-ai-tagging", json=request_data) @@ -460,11 +449,11 @@ def test_enable_ai_tagging_empty_list(self, client): def test_enable_ai_tagging_missing_field(self, client): """Test enabling AI tagging without folder_ids field.""" - request_data = {} + request_data: dict[str, list[str]] = {} response = client.post("/folders/enable-ai-tagging", json=request_data) - assert response.status_code == 422 # Validation error + assert response.status_code == 422 @patch("app.routes.folders.db_enable_ai_tagging_batch") def test_enable_ai_tagging_database_error(self, mock_enable_batch, client): @@ -493,13 +482,10 @@ def test_enable_ai_tagging_background_processing_called( assert response.status_code == 200 - # Verify background processing was triggered app_state = client.app.state app_state.executor.submit.assert_called_once() - # ============================================================================ # POST /folders/disable-ai-tagging - Disable AI Tagging Tests - # ============================================================================ @patch("app.routes.folders.db_disable_ai_tagging_batch") def test_disable_ai_tagging_success(self, mock_disable_batch, client): @@ -545,7 +531,7 @@ def test_disable_ai_tagging_single_folder(self, mock_disable_batch, client): def test_disable_ai_tagging_empty_list(self, client): """Test disabling AI tagging with empty folder_ids list.""" - request_data = {"folder_ids": []} + request_data: dict[str, list[str]] = {"folder_ids": []} response = client.post("/folders/disable-ai-tagging", json=request_data) @@ -557,11 +543,11 @@ def test_disable_ai_tagging_empty_list(self, client): def test_disable_ai_tagging_missing_field(self, client): """Test disabling AI tagging without folder_ids field.""" - request_data = {} + request_data: dict[str, list[str]] = {} response = client.post("/folders/disable-ai-tagging", json=request_data) - assert response.status_code == 422 # Validation error + assert response.status_code == 422 @patch("app.routes.folders.db_disable_ai_tagging_batch") def test_disable_ai_tagging_database_error(self, mock_disable_batch, client): @@ -590,13 +576,11 @@ def test_disable_ai_tagging_no_background_processing( assert response.status_code == 200 - # Verify NO background processing was triggered (unlike enable) + # Unlike enable, disabling queues no background work. app_state = client.app.state app_state.executor.submit.assert_not_called() - # ============================================================================ # DELETE /folders/delete-folders - Delete Folders Tests - # ============================================================================ @patch("app.routes.folders.db_delete_folders_batch") def test_delete_folders_success(self, mock_delete_batch, client): @@ -745,9 +729,7 @@ def test_delete_folders_database_error(self, mock_delete_batch, client): assert data["detail"]["success"] is False assert data["detail"]["error"] == "Internal server error" - # ============================================================================ # GET /folders/all-folders - Get All Folders Tests - # ============================================================================ @patch("app.routes.folders.db_get_all_folder_details") def test_get_all_folders_success( @@ -765,7 +747,6 @@ def test_get_all_folders_success( assert data["data"]["total_count"] == 2 assert len(data["data"]["folders"]) == 2 - # Check first folder details first_folder = data["data"]["folders"][0] assert first_folder["folder_id"] == "folder-id-1" assert first_folder["folder_path"] == "/home/user/photos" @@ -804,9 +785,7 @@ def test_get_all_folders_database_error(self, mock_get_all_folders, client): assert data["detail"]["success"] is False assert data["detail"]["error"] == "Internal server error" - # ============================================================================ # Edge Cases and Error Handling Tests - # ============================================================================ def test_add_folder_malformed_json(self, client): """Test adding folder with malformed JSON.""" @@ -854,10 +833,6 @@ def test_disable_ai_tagging_no_folders_updated(self, mock_disable_batch, client) assert data["success"] is True assert data["data"]["updated_count"] == 0 - # ============================================================================ - # Unit Tests - # ============================================================================ - class TestFoldersUnit: @@ -1324,9 +1299,6 @@ def test_db_get_direct_child_folders(self, test_db): } -# ============================================================================ -# Integration & Workflow Tests -# ============================================================================ class TestFoldersIntegration: @patch("app.routes.folders.folder_util_add_folder_tree") @patch("app.routes.folders.db_update_parent_ids_for_subtree") @@ -1457,7 +1429,6 @@ def test_complete_folder_lifecycle( enable_response = client.post("/folders/enable-ai-tagging", json=enable_request) assert enable_response.status_code == 200 - # Delete folders mock_delete_batch.return_value = 2 delete_response = client.request( "DELETE", diff --git a/backend/tests/test_image_embeddings.py b/backend/tests/test_image_embeddings.py index 0871953b2..9f270e2bf 100644 --- a/backend/tests/test_image_embeddings.py +++ b/backend/tests/test_image_embeddings.py @@ -146,8 +146,7 @@ def test_count_embeddings_with_and_without_model_version_filter(self): assert db_count_embeddings("siglip2-base-patch16-224") == 1 assert db_count_embeddings("siglip2-large-patch16-384") == 1 - # Now that the DB is genuinely isolated per test, this is exact -- - # no leftover rows from real usage or other tests to tolerate. + # Exact: each test gets its own database, so no stray rows to tolerate. assert db_count_embeddings() == 2 def test_get_all_embeddings_empty_returns_empty_matrix(self): diff --git a/backend/tests/test_memories_route.py b/backend/tests/test_memories_route.py index c999362cc..45007cae6 100644 --- a/backend/tests/test_memories_route.py +++ b/backend/tests/test_memories_route.py @@ -10,10 +10,6 @@ from app.routes import memories as memories_route from app.schemas.user_preferences import MemoriesPreferences -# ############################## -# Pytest Fixtures -# ############################## - @pytest.fixture def client() -> Iterator[TestClient]: @@ -115,9 +111,7 @@ def make_run(status: str) -> Dict[str, Any]: } -# ############################## # POST /memories/generate -# ############################## class TestGenerateMemories: @@ -367,9 +361,7 @@ def test_returns_500_when_the_database_fails(self, client: TestClient): assert detail["error"] == "Internal server error" -# ############################## # GET /memories/today -# ############################## class TestTodayMemory: @@ -454,9 +446,7 @@ def test_returns_500_when_the_database_fails(self, client: TestClient): assert client.get("/memories/today").status_code == 500 -# ############################## # GET /memories -# ############################## class TestListMemories: @@ -511,9 +501,7 @@ def test_rejects_out_of_range_pagination( assert client.get("/memories", params=params).status_code == 422 -# ############################## # GET /memories/{memory_id} -# ############################## class TestGetMemory: @@ -557,9 +545,7 @@ def test_literal_routes_win_over_the_id_route(self, client: TestClient, path: st by_id.assert_not_called() -# ############################## # PATCH /memories/{memory_id} -# ############################## class TestUpdateMemory: @@ -605,9 +591,7 @@ def test_unknown_id_returns_404(self, client: TestClient): assert response.status_code == 404 -# ############################## # DELETE /memories/{memory_id} -# ############################## class TestDeleteMemory: @@ -623,9 +607,7 @@ def test_unknown_id_returns_404(self, client: TestClient): assert client.delete("/memories/missing").status_code == 404 -# ############################## # GET /memories/status -# ############################## class TestMemoryStatus: diff --git a/backend/tests/test_metadata.py b/backend/tests/test_metadata.py index cc08d9d76..437c0f78f 100644 --- a/backend/tests/test_metadata.py +++ b/backend/tests/test_metadata.py @@ -13,10 +13,6 @@ db_update_metadata, ) -# ############################## -# Pytest Fixtures -# ############################## - @pytest.fixture(scope="function") def test_db(monkeypatch: pytest.MonkeyPatch) -> Iterator[str]: @@ -70,11 +66,6 @@ def __getattr__(self, name: str) -> Any: return getattr(self._conn, name) -# ############################## -# Table creation -# ############################## - - class TestCreateMetadataTable: def test_seeds_a_single_empty_row(self, test_db): assert stored_rows(test_db) == [("{}",)] @@ -98,11 +89,6 @@ def test_survives_a_connection_failure(self): db_create_metadata_table() -# ############################## -# Reading metadata -# ############################## - - class TestGetMetadata: def test_returns_none_without_a_row(self, test_db): write_raw(test_db, "DELETE FROM metadata") @@ -117,11 +103,6 @@ def test_returns_none_for_invalid_json(self, test_db): assert db_get_metadata() is None -# ############################## -# Updating metadata -# ############################## - - class TestUpdateMetadata: def test_stores_nested_values(self, test_db): expected: Dict[str, Any] = { diff --git a/backend/tests/test_semantic_search_route.py b/backend/tests/test_semantic_search_route.py index 9cb97f642..5887ea041 100644 --- a/backend/tests/test_semantic_search_route.py +++ b/backend/tests/test_semantic_search_route.py @@ -135,13 +135,9 @@ def test_returns_matches_above_threshold_sorted_desc( mock_get_path.return_value = "/models/text.onnx" mock_exists.return_value = True - # Two embeddings that both clear SIGLIP2_MATCH_THRESHOLD (0.01) with - # clearly distinct scores after 4dp rounding (dot=0.16 -> ~0.7782, - # dot=0.13 -> ~0.1067 -- realistic dot-product magnitudes per the - # calibrated scoring, not the near-1.0/near-0.0 saturation you'd get - # from naive orthogonal/aligned toy vectors). img_high has the - # *lower* score despite being listed first in db_get_all_embeddings, - # so a broken sort would put it first in the response too. + # Both clear SIGLIP2_MATCH_THRESHOLD with distinct scores at realistic dot + # magnitudes, not saturated toy vectors. img_high scores *lower* despite + # being listed first, so a broken sort shows. mock_get_all_embeddings.return_value = ( ["img_high", "img_highest"], np.array([[0.13, 0.0], [0.16, 0.0]], dtype=np.float32), @@ -157,12 +153,8 @@ def test_returns_matches_above_threshold_sorted_desc( ) mock_get_text_model.return_value = mock_text_model - # db_get_images_by_ids preserves caller-supplied ID order in the - # real implementation (verified in test_image_embeddings.py's - # sibling tests) -- mirror that contract here rather than asserting - # the route re-sorts independently. It doesn't: matched_pairs is - # sorted once, and everything downstream (matched_ids, the DB call, - # the response) just follows that order through. + # The real db_get_images_by_ids preserves caller order, so mirror that + # contract: the route sorts matched_pairs once and everything follows. mock_get_images_by_ids.side_effect = lambda ids: [ _image_row(img_id, f"/p/{img_id}.jpg") for img_id in ids ] @@ -189,9 +181,7 @@ def test_returns_matches_above_threshold_sorted_desc( # Query must be stripped + lowercased before templating, so casing/ # whitespace differences hit the calibration-validated regime. mock_tokenize.assert_called_once_with("This is a photo of beach.") - # The definitive proof of correct sorting: matched_ids (built from - # matched_pairs.sort(..., reverse=True)) must already be in - # descending-score order by the time it reaches this call. + # The proof the sort works: ids reach this call in descending-score order. mock_get_images_by_ids.assert_called_once_with(["img_highest", "img_high"]) @patch("app.database.image_embeddings.db_get_all_embeddings") @@ -207,9 +197,8 @@ def test_whitespace_only_query_returns_400( mock_get_path, mock_get_all_embeddings, ): - # min_length=1 on the FastAPI Query param only checks raw string - # length, so a whitespace-only query slips past it -- the route's - # own strip().lower() + empty-check is what should catch this. + # min_length=1 checks the raw string, so a whitespace-only query gets past it; + # the route's own strip() and empty check must catch it. mock_registry_keys.return_value = ("siglip2_base_vision", "siglip2_base_text") mock_tok_key.return_value = "siglip2_base_tokenizer" mock_get_path.return_value = "/models/text.onnx" diff --git a/backend/tests/test_user_preferences.py b/backend/tests/test_user_preferences.py index 6ff03cb54..4619718d7 100644 --- a/backend/tests/test_user_preferences.py +++ b/backend/tests/test_user_preferences.py @@ -9,11 +9,6 @@ client = TestClient(app) -# ############################## -# Pytest Fixtures -# ############################## - - @pytest.fixture def sample_update_request(): """Sample update user preferences request data.""" @@ -47,11 +42,6 @@ def empty_metadata(): return {} -# ############################## -# Test Classes -# ############################## - - class TestUserPreferencesAPI: """Test class for User Preferences API endpoints.""" @@ -567,11 +557,6 @@ def test_partial_update_allows_none(self): ) -# ############################## -# Memories preferences -# ############################## - - class TestMemoriesPreferences: def test_defaults_appear_when_the_key_is_absent(self): with patch( diff --git a/backend/tests/test_video_capture_date.py b/backend/tests/test_video_capture_date.py index 46dc09faf..7d38a6b3f 100644 --- a/backend/tests/test_video_capture_date.py +++ b/backend/tests/test_video_capture_date.py @@ -164,9 +164,7 @@ def test_an_unparseable_creation_string_is_refused(self, video): assert video_capture_date_candidates(path)[0] is None -# ############################## # How videos choose a capture date -# ############################## def sidecar_for(path, exif_date: str): diff --git a/backend/tests/test_xmp_stripping.py b/backend/tests/test_xmp_stripping.py index cc7e41a32..589d28d3b 100644 --- a/backend/tests/test_xmp_stripping.py +++ b/backend/tests/test_xmp_stripping.py @@ -487,12 +487,8 @@ def test_marker_is_found_across_any_block_boundary(self, block): # --- the namespace spelled some other way, in packets that cannot be parsed -- -# -# A parsed packet is judged by its tree, which resolves every spelling. These -# cover the packets we cannot parse: besides literally, XML can spell our -# namespace only with numeric character references, declared entities, or in -# UTF-16/32. PictoPy always writes it literally in UTF-8, so these exist only -# if another tool both re-encodes our data and breaks the XML. +# A parsed tree resolves every spelling; unparseable packets can still hide ours in +# character references, entities or UTF-16/32, which only another tool produces. UNCLOSED = b"" + K + b"" # description never closed diff --git a/backend/tests/test_yolo_mapping.py b/backend/tests/test_yolo_mapping.py index 7e10edaeb..6f9ea48a1 100644 --- a/backend/tests/test_yolo_mapping.py +++ b/backend/tests/test_yolo_mapping.py @@ -8,10 +8,6 @@ from app.database.yolo_mapping import db_create_YOLO_classes_table -# ############################## -# Pytest Fixtures -# ############################## - @pytest.fixture(scope="function") def test_db(monkeypatch: pytest.MonkeyPatch) -> Iterator[str]: @@ -43,11 +39,6 @@ def fetch_mappings(db_path: str) -> List[Tuple[int, str]]: return rows -# ############################## -# Table creation -# ############################## - - class TestCreateYOLOClassesTable: def test_inserts_class_names_by_index(self, test_db, monkeypatch): set_class_names(monkeypatch, ["person", "bicycle", "car"]) @@ -68,7 +59,7 @@ def test_replaces_renamed_classes(self, test_db, monkeypatch): set_class_names(monkeypatch, ["old-person", "old-car"]) db_create_YOLO_classes_table() - # Same ids, new names -> INSERT OR REPLACE overwrites in place + # Same ids, new names: the upsert overwrites them in place. set_class_names(monkeypatch, ["person", "car"]) db_create_YOLO_classes_table() diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 7ddeedde3..855c981ea 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -383,10 +383,8 @@ fn main() { .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app, event| { - // The close handler and the tray item both stop the tunnel already, - // but neither covers every way the app can exit. This is the one - // path all of them pass through, and an ssh child outliving - // PictoPy would leave an album reachable from the internet. + // The close handler and tray item stop the tunnel, but miss some exit + // paths; this one they all pass through. if let tauri::RunEvent::Exit = event { services::tunnel::shutdown(app); } diff --git a/frontend/src-tauri/src/services/tunnel.rs b/frontend/src-tauri/src/services/tunnel.rs index d082b53a8..521a7a9bb 100644 --- a/frontend/src-tauri/src/services/tunnel.rs +++ b/frontend/src-tauri/src/services/tunnel.rs @@ -1,13 +1,6 @@ -//! Exposing the share server beyond the LAN through an SSH reverse tunnel. -//! -//! Nothing is bundled to make this work: every desktop platform ships an ssh -//! client, and both providers below accept a plain port forward. That avoids a -//! ~40MB third-party binary in the installer, and the supply-chain question of -//! pinning one that upstream publishes from unversioned URLs. -//! -//! The child process is owned here so it dies with the app. An orphaned tunnel -//! is an album left reachable from the internet, which is a good deal worse -//! than the orphaned LAN listener the backend already guards against. +//! Exposes the share server beyond the LAN through an SSH reverse tunnel. Uses the +//! platform's ssh rather than bundling a ~40MB binary served from unversioned URLs. +//! The child is owned here so it dies with the app: an orphan exposes an album. use std::path::PathBuf; use std::sync::Mutex; @@ -36,11 +29,8 @@ struct Provider { url_suffix: &'static str, } -/// Tried in order until one answers. srv.us is the intended second entry, and -/// needs a dedicated key generated first, so it lands separately rather than -/// half-built here. A fallback matters because a provider can fail while -/// looking healthy: one accepted connections and issued URLs for a tunnel it -/// then never routed anything to. +/// Tried in order until one answers; srv.us is the intended second, pending a +/// key of its own. A provider can look healthy while routing nothing it issues. const PROVIDERS: [Provider; 1] = [Provider { name: "localhost.run", destination: "nokey@localhost.run", @@ -48,10 +38,8 @@ const PROVIDERS: [Provider; 1] = [Provider { url_suffix: ".lhr.life", }]; -/// The part of a spawned process this module actually needs. -/// -/// Named so the lifecycle can be exercised without spawning ssh; the real -/// implementation is the shell plugin's child. +/// Named so the lifecycle can be tested without spawning ssh; in production this +/// is the shell plugin's child. pub trait TunnelChild: Send + 'static { fn pid(&self) -> u32; fn kill(self) -> Result<(), String>; @@ -74,21 +62,16 @@ struct ActiveTunnel { child: C, } -/// The tracked child, and whether the app has begun shutting down. -/// -/// Both live under one lock on purpose. Kept apart, a shutdown could land -/// between a child being spawned and being tracked, find nothing to kill, and -/// let the start install an ssh process that then outlives the application. +/// Child and shutdown flag share one lock on purpose: kept apart, a shutdown +/// landing mid-spawn finds nothing to kill and the ssh process outlives the app. struct Tracked { tunnel: Option>, closed: bool, } pub struct TunnelStateOf { - /// Held across an entire start or stop, spawn included. Without it two - /// concurrent starts could both find no tunnel, both spawn a child, and - /// leave whichever installed first running with nothing holding its - /// handle — an ssh process nobody can stop. + /// Held across a whole start or stop, spawn included: without it two starts + /// both spawn, and the loser's ssh process is left with no handle to kill it. lifecycle: AsyncMutex<()>, tracked: Mutex>, } @@ -113,10 +96,8 @@ impl TunnelStateOf { .and_then(|tracked| tracked.tunnel.as_ref().and_then(|t| t.url.clone())) } - /// Start tracking a child, before it has announced anything. - /// - /// Refuses once shutdown has been requested, killing the child rather than - /// storing it: by then nothing will come back to stop it. + /// Refuses once shutdown is requested, killing the child rather than storing + /// it: by then nothing will come back to stop it. fn track(&self, child: C) -> Result<(), String> { let mut tracked = self.tracked.lock().map_err(|_| "tunnel state lost")?; if tracked.closed { @@ -136,10 +117,8 @@ impl TunnelStateOf { Ok(()) } - /// Kill whatever is tracked and stop tracking it. - /// - /// The pid is read first because killing consumes the handle: it cannot be - /// put back for a retry, so a failure has to name the process instead. + /// Reads the pid first because killing consumes the handle -- a failure has + /// to name the process, since it cannot be put back for a retry. fn stop(&self) -> Result<(), String> { self.take_and_kill(false) } @@ -166,10 +145,8 @@ impl TunnelStateOf { }) } - /// Drop a tunnel from state, but only if it is still the current one. - /// - /// A tunnel that died after a newer one replaced it must not clear the - /// newer one's URL, which is why this checks identity rather than taking. + /// Checks identity rather than taking: a tunnel that died after a newer one + /// replaced it must not clear the newer one's URL. fn forget(&self, url: &str) { if let Ok(mut tracked) = self.tracked.lock() { let matches = tracked @@ -190,12 +167,9 @@ impl Default for TunnelStateOf { } } -/// The public URL in a line of provider output, if there is one. -/// -/// Split on whitespace rather than pattern-matching the sentence around it: the -/// wording is the provider's to change, and one has already been observed -/// printing something different from its own documentation. Each event is a -/// whole line, because the shell plugin frames process output with read_line. +/// Splits on whitespace rather than matching the sentence around it -- the +/// wording is the provider's to change, and one already differs from its docs. +/// Each event is one line: the shell plugin frames output with read_line. fn find_url(line: &str, suffix: &str) -> Option { line.split_whitespace() .filter(|token| token.starts_with("https://")) @@ -349,13 +323,9 @@ pub fn tunnel_status(state: State<'_, TunnelState>) -> Result, St Ok(state.current_url()) } -/// Kill the tunnel on the way out, from outside a command context. -/// -/// Deliberately does not take the lifecycle lock: this runs on the exit path, -/// where blocking on an in-flight start would be worse than racing it. Racing -/// it is safe because the refusal is recorded under the same lock the start -/// uses to track its child, so a child spawned but not yet tracked is killed -/// by whichever of the two arrives second. +/// Takes no lifecycle lock: on the exit path, blocking on an in-flight start is +/// worse than racing it, and the refusal is recorded under the tracking lock, so +/// a child spawned but not yet tracked dies to whichever arrives second. pub fn shutdown(app: &AppHandle) { if let Some(state) = app.try_state::() { let _ = state.close_permanently(); @@ -465,9 +435,8 @@ mod tests { assert!(error.contains("may still be running"), "{error}"); } - // Exit can land between ssh being spawned and the handle being tracked. - // Whichever arrives second has to kill it, or the process outlives the app - // with an album still forwarding. + // Exit can land between ssh spawning and its handle being tracked; whichever + // arrives second must kill it or the process outlives the app. #[test] fn a_child_spawned_during_shutdown_is_killed_not_stored() { let killed = Arc::new(AtomicBool::new(false)); diff --git a/frontend/src/api/api-functions/albums.ts b/frontend/src/api/api-functions/albums.ts index 6f755f8f7..b2efc5698 100644 --- a/frontend/src/api/api-functions/albums.ts +++ b/frontend/src/api/api-functions/albums.ts @@ -12,9 +12,6 @@ import { RemoveImagesFromAlbumRequest, } from '@/types/Album'; -/** - * Get all albums - */ export const getAllAlbums = async (): Promise => { const response = await apiClient.get( albumsEndpoints.getAllAlbums, @@ -22,10 +19,6 @@ export const getAllAlbums = async (): Promise => { return response.data; }; -/** - * Get album by ID - * @param albumId - Album UUID - */ export const getAlbumById = async (albumId: string): Promise => { const response = await apiClient.get( albumsEndpoints.getAlbumById(albumId), @@ -33,10 +26,6 @@ export const getAlbumById = async (albumId: string): Promise => { return response.data; }; -/** - * Create a new album - * @param data - Album creation data - */ export const createAlbum = async ( data: CreateAlbumRequest, ): Promise => { @@ -47,10 +36,6 @@ export const createAlbum = async ( return response.data; }; -/** - * Create an album from a curated memory's photos - * @param data - Source memory and the new album's name - */ export const createAlbumFromMemory = async ( data: CreateAlbumFromMemoryRequest, ): Promise> => { @@ -61,11 +46,6 @@ export const createAlbumFromMemory = async ( return response.data; }; -/** - * Update an existing album - * @param albumId - Album UUID - * @param data - Album update data - */ export const updateAlbum = async ( albumId: string, data: UpdateAlbumRequest, @@ -77,10 +57,6 @@ export const updateAlbum = async ( return response.data; }; -/** - * Delete an album - * @param albumId - Album UUID - */ export const deleteAlbum = async (albumId: string): Promise => { const response = await apiClient.delete( albumsEndpoints.deleteAlbum(albumId), @@ -88,11 +64,6 @@ export const deleteAlbum = async (albumId: string): Promise => { return response.data; }; -/** - * Add images to an album - * @param albumId - Album UUID - * @param data - Image IDs to add - */ export const addImagesToAlbum = async ( albumId: string, data: AddImagesToAlbumRequest, @@ -104,11 +75,7 @@ export const addImagesToAlbum = async ( return response.data; }; -/** - * Get all images in an album - * @param albumId - Album UUID - * @param data - Optional password for locked albums - */ +// Reads use POST so a locked album's password stays out of the URL. export const getAlbumImages = async ( albumId: string, data?: GetAlbumImagesRequest, @@ -120,11 +87,6 @@ export const getAlbumImages = async ( return response.data; }; -/** - * Remove a single image from an album - * @param albumId - Album UUID - * @param imageId - Image UUID - */ export const removeImageFromAlbum = async ( albumId: string, imageId: string, @@ -135,11 +97,6 @@ export const removeImageFromAlbum = async ( return response.data; }; -/** - * Remove multiple images from an album - * @param albumId - Album UUID - * @param data - Image IDs to remove - */ export const removeMultipleImagesFromAlbum = async ( albumId: string, data: RemoveImagesFromAlbumRequest, diff --git a/frontend/src/api/api-functions/face_clusters.ts b/frontend/src/api/api-functions/face_clusters.ts index 7d521059e..57d3d89a6 100644 --- a/frontend/src/api/api-functions/face_clusters.ts +++ b/frontend/src/api/api-functions/face_clusters.ts @@ -4,7 +4,6 @@ import { APIResponse } from '@/types/API'; import { BackendRes } from '@/hooks/useQueryExtension'; import type { Image, ImageMetadata, Video } from '@/types/Media'; -//Request Types export interface RenameClusterRequest { clusterId: string; newName: string; @@ -21,7 +20,6 @@ export interface FetchSearchedFacesBase64Request { base64_data: string; } -//Response Types /** A photo as the face routes return it: less than a full `Image`. */ export interface FacePhoto { id: string; diff --git a/frontend/src/api/api-functions/folders.ts b/frontend/src/api/api-functions/folders.ts index d4fc619bc..3f23ad1ef 100644 --- a/frontend/src/api/api-functions/folders.ts +++ b/frontend/src/api/api-functions/folders.ts @@ -3,7 +3,6 @@ import { apiClient, syncApiClient } from '../axiosConfig'; import { APIResponse } from '@/types/API'; import { FolderTaggingStatusResponse } from '@/types/FolderStatus'; -// Request Types export interface AddFolderRequest { folder_path: string; parent_folder_id?: string; @@ -23,7 +22,6 @@ export interface SyncFolderRequest { folder_id: string; } -// API Functions export const getAllFolders = async (): Promise => { const response = await apiClient.get( foldersEndpoints.getAllFolders, diff --git a/frontend/src/api/api-functions/index.ts b/frontend/src/api/api-functions/index.ts index 0e9e23867..8ac34176b 100644 --- a/frontend/src/api/api-functions/index.ts +++ b/frontend/src/api/api-functions/index.ts @@ -1,4 +1,3 @@ -// Export all API functions export * from './face_clusters'; export * from './images'; export * from './videos'; diff --git a/frontend/src/api/api-functions/memories.ts b/frontend/src/api/api-functions/memories.ts index 079725c02..c1a1b876e 100644 --- a/frontend/src/api/api-functions/memories.ts +++ b/frontend/src/api/api-functions/memories.ts @@ -2,8 +2,6 @@ import { memoriesEndpoints } from '../apiEndpoints'; import { apiClient } from '../axiosConfig'; import type { BackendRes } from '@/hooks/useQueryExtension'; -// Data Types - export type MemoryEventType = 'anniversary' | 'import_event' | 'semantic_event'; export type MemoryStatus = 'pending' | 'complete' | 'failed' | 'empty'; @@ -63,10 +61,7 @@ export interface MemoryCard { /** A memory with its full image set, for the story viewer. */ export interface MemoryStory extends MemoryCard { images: MemoryImage[]; - /** - * Separate from images because they are separate tables. sort_order runs - * across both, so the viewer merges them into one sequence. - */ + /** A separate table from images, but sort_order runs across both. */ videos: MemoryVideo[]; signals: Record | null; } @@ -102,8 +97,6 @@ export interface UpdateMemoryRequest { notified?: boolean; } -// API Functions - /** List memory cards, newest first. */ export const getMemories = async ( params?: ListMemoriesParams, diff --git a/frontend/src/api/api-functions/share.ts b/frontend/src/api/api-functions/share.ts index ef9952f51..d4f3f6ab7 100644 --- a/frontend/src/api/api-functions/share.ts +++ b/frontend/src/api/api-functions/share.ts @@ -3,19 +3,11 @@ import { apiClient } from '../axiosConfig'; import type { BackendRes } from '@/hooks/useQueryExtension'; import { CreateShareRequest, Share } from '@/types/Share'; -/** - * Every album currently being served on the local network - */ export const getShares = async (): Promise> => { const response = await apiClient.get(shareEndpoints.getShares); return response.data; }; -/** - * Start serving an album on the local network - * @param albumId - Album UUID - * @param data - Optional expiry and password - */ export const createShare = async ( albumId: string, data: CreateShareRequest = {}, @@ -27,10 +19,7 @@ export const createShare = async ( return response.data; }; -/** - * Stop serving a share. The network listener closes with the last one. - * @param token - The share's token - */ +// The network listener closes with the last share revoked. export const revokeShare = async (token: string): Promise> => { const response = await apiClient.delete(shareEndpoints.revokeShare(token)); return response.data; diff --git a/frontend/src/api/api-functions/user_preferences.ts b/frontend/src/api/api-functions/user_preferences.ts index 378de6d5c..0a91e8245 100644 --- a/frontend/src/api/api-functions/user_preferences.ts +++ b/frontend/src/api/api-functions/user_preferences.ts @@ -2,8 +2,6 @@ import { userPreferencesEndpoints } from '../apiEndpoints'; import { apiClient } from '../axiosConfig'; import { APIResponse } from '@/types/API'; -// User Preferences Types - /** Relative importance of each memory scoring signal. Normalized server-side. */ export interface MemoryScoringWeights { favourite: number; @@ -59,7 +57,6 @@ export interface UpdateUserPreferencesResponse extends APIResponse { user_preferences: UserPreferencesData; } -// API Functions export const getUserPreferences = async (): Promise => { const response = await apiClient.get( diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts index d3d37eed2..59a45568b 100644 --- a/frontend/src/app/store.ts +++ b/frontend/src/app/store.ts @@ -28,7 +28,5 @@ export const rootReducer = combineReducers({ export const store = configureStore({ reducer: rootReducer, }); -// Infer the `RootState` and `AppDispatch` types from the store itself export type RootState = ReturnType; -// Inferred type: {loader: LoaderState, onboarding: OnboardingState, images: ImageState, ...} export type AppDispatch = typeof store.dispatch; diff --git a/frontend/src/components/Albums/AddImagesToAlbumDialog.tsx b/frontend/src/components/Albums/AddImagesToAlbumDialog.tsx index 94a2b5872..8256697ee 100644 --- a/frontend/src/components/Albums/AddImagesToAlbumDialog.tsx +++ b/frontend/src/components/Albums/AddImagesToAlbumDialog.tsx @@ -107,7 +107,6 @@ export const AddImagesToAlbumDialog: React.FC = ({
- {/* Search Input */}
= ({ className="pl-10" />
- {/* Image Grid */} {isLoading ? (
@@ -181,7 +179,6 @@ export const AddImagesToAlbumDialog: React.FC = ({ )}
- {/* Selected Count */} {selectedImages.size > 0 && (

diff --git a/frontend/src/components/Albums/AlbumCard.tsx b/frontend/src/components/Albums/AlbumCard.tsx index 35dcbab3c..1a30be994 100644 --- a/frontend/src/components/Albums/AlbumCard.tsx +++ b/frontend/src/components/Albums/AlbumCard.tsx @@ -47,7 +47,6 @@ export const AlbumCard: React.FC = ({ onClick={onClick} > - {/* Album Cover Image */}

= ({ img.src = placeholderSrc; }} /> - {/* Lock Icon for Locked Albums */} {album.is_locked && (
)} - {/* Sharing Badge */} {isSharing && (
Sharing
)} - {/* Actions Menu */}
@@ -105,7 +101,6 @@ export const AlbumCard: React.FC = ({
- {/* Album Info */}

{album.name}

{album.description && ( diff --git a/frontend/src/components/Albums/CreateAlbumDialog.tsx b/frontend/src/components/Albums/CreateAlbumDialog.tsx index ca045d9ad..7c39f32ce 100644 --- a/frontend/src/components/Albums/CreateAlbumDialog.tsx +++ b/frontend/src/components/Albums/CreateAlbumDialog.tsx @@ -105,7 +105,6 @@ export const CreateAlbumDialog: React.FC = ({
- {/* Album Name */}
- {/* Album Description */}