diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index e639015c3..cad360551 100644 --- a/backend/app/config/settings.py +++ b/backend/app/config/settings.py @@ -165,10 +165,9 @@ def _get_env_int( # bare-noun queries score low in absolute terms; 0.02 measured to 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) @@ -180,12 +179,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..185912536 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 @@ -368,7 +365,6 @@ 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 @@ -377,7 +373,6 @@ def db_add_images_to_album(album_id: str, image_ids: list[str]): 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 ec537eaf5..e3f108ba3 100644 --- a/backend/app/database/faces.py +++ b/backend/app/database/faces.py @@ -83,7 +83,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( @@ -119,7 +118,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 @@ -205,18 +203,15 @@ def get_all_face_embeddings(): "tags": [], } - # Add tag if it exists if tag_name: images_dict[image_id]["tags"].append(tag_name) - # Convert to list and set tags to None if empty images = [] for image_data in images_dict.values(): if not image_data["tags"]: image_data["tags"] = None images.append(image_data) - # Sort by path images.sort(key=lambda x: x["path"]) return images finally: @@ -243,7 +238,6 @@ def db_get_faces_unassigned_clusters() -> List[Dict[str, Union[FaceId, FaceEmbed faces = [] for row in rows: face_id, image_id, embeddings_json = row - # Convert JSON string back to numpy array embeddings = np.array(json.loads(embeddings_json)) faces.append( {"face_id": face_id, "image_id": image_id, "embeddings": embeddings} @@ -281,7 +275,6 @@ def db_get_all_faces_with_cluster_names() -> ( faces = [] for row in rows: face_id, image_id, embeddings_json, cluster_name = row - # Convert JSON string back to numpy array embeddings = np.array(json.loads(embeddings_json)) faces.append( { @@ -403,14 +396,12 @@ def db_get_cluster_mean_embeddings() -> List[Dict[str, Union[str, FaceEmbedding] cluster_embeddings = {} 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 diff --git a/backend/app/database/folders.py b/backend/app/database/folders.py index 8bf501dd3..1c282f630 100644 --- a/backend/app/database/folders.py +++ b/backend/app/database/folders.py @@ -5,7 +5,6 @@ from app.config.settings import DATABASE_PATH from app.logging.setup_logging import get_logger -# Initialize logger logger = get_logger(__name__) # Type definitions @@ -193,7 +192,6 @@ def db_delete_folders_batch(folder_ids: List[FolderId]) -> int: cursor.execute("PRAGMA foreign_keys = ON;") conn.commit() - # Create placeholders for the IN clause placeholders = ",".join("?" * len(folder_ids)) cursor.execute( @@ -323,7 +321,6 @@ def db_update_ai_tagging_batch( cursor = conn.cursor() try: - # Create placeholders for the IN clause placeholders = ",".join("?" * len(folder_ids)) cursor.execute( @@ -399,10 +396,8 @@ def db_get_folder_ids_by_paths( cursor = conn.cursor() try: - # Convert all paths to absolute paths abs_paths = [os.path.abspath(path) for path in folder_paths] - # Create placeholders for the IN clause placeholders = ",".join("?" * len(abs_paths)) cursor.execute( @@ -412,7 +407,6 @@ def db_get_folder_ids_by_paths( results = cursor.fetchall() - # Create a mapping from folder_path to folder_id path_to_id = {folder_path: folder_id for folder_path, folder_id in results} return path_to_id 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/images.py b/backend/app/database/images.py index 0d2f1ad7f..bf03e0ebc 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -12,7 +12,6 @@ ) from app.logging.setup_logging import get_logger -# Initialize logger logger = get_logger(__name__) # Type definitions @@ -95,7 +94,6 @@ def db_create_images_table() -> None: """ ) - # Create indexes for Memories feature queries cursor.execute("CREATE INDEX IF NOT EXISTS ix_images_latitude ON images(latitude)") cursor.execute( "CREATE INDEX IF NOT EXISTS ix_images_longitude ON images(longitude)" @@ -107,7 +105,6 @@ def db_create_images_table() -> None: "CREATE INDEX IF NOT EXISTS ix_images_favourite_captured_at ON images(isFavourite, captured_at)" ) - # Create new image_classes junction table cursor.execute( """ CREATE TABLE IF NOT EXISTS image_classes ( @@ -191,7 +188,6 @@ def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]: cursor = conn.cursor() try: - # Build the query with optional WHERE clause query = """ SELECT i.id, @@ -262,14 +258,12 @@ def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]: if tag_name and tag_name not in images_dict[image_id]["tags"]: images_dict[image_id]["tags"].append(tag_name) - # Convert to list and set tags to None if empty images = [] for image_data in images_dict.values(): if not image_data["tags"]: image_data["tags"] = None images.append(image_data) - # Sort by path images.sort(key=lambda x: x["path"]) return images @@ -457,7 +451,6 @@ def db_get_images_by_folder_ids( cursor = conn.cursor() try: - # Create placeholders for the IN clause placeholders = ",".join("?" for _ in folder_ids) cursor.execute( f""" @@ -559,7 +552,6 @@ def db_delete_images_by_ids(image_ids: List[ImageId]) -> bool: cursor = conn.cursor() try: - # Create placeholders for the IN clause placeholders = ",".join("?" for _ in image_ids) cursor.execute( f"DELETE FROM images WHERE id IN ({placeholders})", diff --git a/backend/app/database/memories.py b/backend/app/database/memories.py index 2aa7a90b3..8a2cd2f7b 100644 --- a/backend/app/database/memories.py +++ b/backend/app/database/memories.py @@ -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 ( @@ -1444,10 +1442,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/metadata.py b/backend/app/database/metadata.py index 7e208ae15..7d4c5d581 100644 --- a/backend/app/database/metadata.py +++ b/backend/app/database/metadata.py @@ -22,7 +22,6 @@ def db_create_metadata_table() -> None: """ ) - # Insert initial row if table is empty cursor.execute("SELECT COUNT(*) FROM metadata") if cursor.fetchone()[0] == 0: cursor.execute("INSERT INTO metadata (metadata) VALUES (?)", ("{}",)) @@ -89,7 +88,6 @@ def db_update_metadata( conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() - # Delete all existing rows and insert new one cursor.execute("DELETE FROM metadata") cursor.execute("INSERT INTO metadata (metadata) VALUES (?)", (metadata_json,)) diff --git a/backend/app/database/semantic_labels.py b/backend/app/database/semantic_labels.py index 18d1c0c24..d61d27c9a 100644 --- a/backend/app/database/semantic_labels.py +++ b/backend/app/database/semantic_labels.py @@ -18,21 +18,17 @@ 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. 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 +44,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 ee88f6a65..381f69508 100644 --- a/backend/app/database/videos.py +++ b/backend/app/database/videos.py @@ -11,7 +11,6 @@ ) from app.logging.setup_logging import get_logger -# Initialize logger logger = get_logger(__name__) # Type definitions @@ -44,10 +43,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/logging/setup_logging.py b/backend/app/logging/setup_logging.py index e8f6b38b9..fd7e86a5c 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,7 +224,6 @@ 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" @@ -247,7 +235,6 @@ def emit(self, record: logging.LogRecord) -> None: record.msg = f"[{module_name}] {msg}" record.args = () - # Clear exception / stack info to avoid duplicate traces record.exc_info = None record.stack_info = None @@ -266,7 +253,6 @@ 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 @@ -284,7 +270,6 @@ 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 diff --git a/backend/app/models/FaceDetector.py b/backend/app/models/FaceDetector.py index 3d4a9f385..20780af8f 100644 --- a/backend/app/models/FaceDetector.py +++ b/backend/app/models/FaceDetector.py @@ -14,7 +14,6 @@ ) from app.utils.face_quality import face_passes_quality_gate -# Initialize logger logger = get_logger(__name__) @@ -62,7 +61,6 @@ def detect_faces(self, image_id: str, image_path: str, forSearch: bool = False): 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..f5400e824 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 a long + # run does not hold up 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/routes/albums.py b/backend/app/routes/albums.py index fdbed2378..8be30448a 100644 --- a/backend/app/routes/albums.py +++ b/backend/app/routes/albums.py @@ -61,14 +61,12 @@ def _internal_error(message: str) -> HTTPException: ) -# GET /albums/ - Get all albums (including locked ones) @router.get("/", response_model=GetAlbumsResponse) def get_albums(): """Get all albums. Always returns both locked and unlocked albums.""" albums = db_get_all_albums() album_list = [] for album in albums: - # Get image count for each album image_ids = db_get_album_images(album["album_id"]) image_count = len(image_ids) is_locked = album["is_locked"] @@ -92,7 +90,6 @@ def get_albums(): return GetAlbumsResponse(success=True, albums=album_list) -# POST /albums/ - Create a new album @router.post("/", response_model=CreateAlbumResponse) def create_album(body: CreateAlbumRequest): existing_album = db_get_album_by_name(body.name) @@ -109,7 +106,6 @@ def create_album(body: CreateAlbumRequest): raise _internal_error(f"Failed to create album: {e}") from e -# POST /albums/from-memory - Create an album from a curated memory @router.post( "/from-memory", response_model=CreateAlbumFromMemoryResponse, @@ -157,7 +153,6 @@ def create_album_from_memory( ) -# GET /albums/{album_id} - Get specific album details @router.get("/{album_id}", response_model=GetAlbumResponse) def get_album(album_id: str = Path(...)): album = db_get_album(album_id) @@ -170,7 +165,6 @@ def get_album(album_id: str = Path(...)): ) try: - # Get image count for the album image_ids = db_get_album_images(album_id) image_count = len(image_ids) @@ -198,7 +192,6 @@ def get_album(album_id: str = Path(...)): ) -# PUT /albums/{album_id} - Update Album @router.put("/{album_id}", response_model=SuccessResponse) def update_album(album_id: str = Path(...), body: UpdateAlbumRequest = Body(...)): album = db_get_album(album_id) @@ -247,7 +240,6 @@ def update_album(album_id: str = Path(...), body: UpdateAlbumRequest = Body(...) ) -# DELETE /albums/{album_id} - Delete an album @router.delete("/{album_id}", response_model=SuccessResponse) def delete_album(album_id: str = Path(...)): album = db_get_album(album_id) @@ -273,11 +265,9 @@ def delete_album(album_id: str = Path(...)): ) -# GET /albums/{album_id}/images - Get all images in an album +# POST, not GET: a locked album's password travels in the body, and GET has no +# body to put it in. @router.post("/{album_id}/images/get", response_model=GetAlbumImagesResponse) -# GET requests do not accept a body by default. -# Since we need to send a password securely, switching this to POST -- necessary. -# Open to suggestions if better approach possible. def get_album_images( album_id: str = Path(...), body: GetAlbumImagesRequest = Body(...) ): @@ -324,7 +314,6 @@ def get_album_images( ) -# POST /albums/{album_id}/images - Add images to an album @router.post("/{album_id}/images", response_model=SuccessResponse) def add_images_to_album(album_id: str = Path(...), body: ImageIdsRequest = Body(...)): album = db_get_album(album_id) @@ -362,7 +351,6 @@ def add_images_to_album(album_id: str = Path(...), body: ImageIdsRequest = Body( ) -# DELETE /albums/{album_id}/images/{image_id} - Remove image from album @router.delete("/{album_id}/images/{image_id}", response_model=SuccessResponse) def remove_image_from_album(album_id: str = Path(...), image_id: str = Path(...)): album = db_get_album(album_id) @@ -390,7 +378,6 @@ def remove_image_from_album(album_id: str = Path(...), image_id: str = Path(...) ) -# DELETE /albums/{album_id}/images - Remove multiple images from album @router.delete("/{album_id}/images", response_model=SuccessResponse) def remove_images_from_album( album_id: str = Path(...), body: ImageIdsRequest = Body(...) diff --git a/backend/app/routes/face_clusters.py b/backend/app/routes/face_clusters.py index 4749b2ebf..0f1a3808e 100644 --- a/backend/app/routes/face_clusters.py +++ b/backend/app/routes/face_clusters.py @@ -86,14 +86,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( @@ -105,7 +103,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(), @@ -203,7 +200,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( @@ -215,10 +211,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"], diff --git a/backend/app/routes/folders.py b/backend/app/routes/folders.py index 95dce44f0..d78f30957 100644 --- a/backend/app/routes/folders.py +++ b/backend/app/routes/folders.py @@ -64,7 +64,6 @@ video_util_process_untagged_videos, ) -# Initialize logger logger = get_logger(__name__) router = APIRouter() @@ -116,18 +115,15 @@ def post_folder_add_sequence(folder_path: str, folder_id: int): folder_data = [] folder_ids_and_paths = db_get_folder_ids_by_path_prefix(folder_path) - # Set all folders to non-recursive (False) 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: @@ -189,7 +185,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)) @@ -199,7 +194,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() @@ -211,7 +205,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( @@ -230,31 +223,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, @@ -265,12 +253,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, @@ -278,19 +264,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) ) @@ -461,20 +444,17 @@ def delete_folders(request: DeleteFoldersRequest): 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 ) @@ -482,7 +462,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 ] @@ -494,7 +473,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, @@ -541,7 +519,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/images.py b/backend/app/routes/images.py index 1df634d05..3daf9448d 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -11,7 +11,6 @@ ) from app.logging.setup_logging import get_logger -# Initialize logger logger = get_logger(__name__) router = APIRouter() @@ -75,7 +74,6 @@ def get_all_images( # Get all images with tags from database (single query with optional filter) images = db_get_all_images(tagged=tagged) - # Convert to response format image_data = [ ImageData( id=image["id"], @@ -240,10 +238,8 @@ def semantic_search_images( # siglip_util_invalidate_text_model for the uninstall interaction. text_model = siglip_util_get_text_model(text_model_path, text_key) text_vec = text_model.get_embedding(input_ids, attention_mask) - # Flatten to 1D vector. (Report shows what shape the method actually returns below) text_vec = np.array(text_vec, dtype=np.float32).flatten() - # scores = 1/(1+np.exp(-(matrix @ text_vec * np.exp(logit_scale) + logit_bias))) dot_products = matrix @ text_vec scaled_logits = dot_products * np.exp(logit_scale) + logit_bias scores = 1 / (1 + np.exp(-scaled_logits)) @@ -257,7 +253,6 @@ def semantic_search_images( if score >= match_threshold: matched_pairs.append((img_id, score)) - # Sort desc by score matched_pairs.sort(key=lambda x: x[1], reverse=True) if not matched_pairs: diff --git a/backend/app/routes/models.py b/backend/app/routes/models.py index e2ce20942..216335847 100644 --- a/backend/app/routes/models.py +++ b/backend/app/routes/models.py @@ -277,7 +277,6 @@ def progress_callback( logger.error(f"Error during setup download: {e}") queue.put_nowait({"status": "error", "message": str(e)}) - # Start the setup in the background task = asyncio.create_task(background_setup()) download_tasks[task_id] = DownloadTaskEntry(queue=queue, task=task) @@ -325,7 +324,6 @@ def progress_callback(percent: float, downloaded: int, total: int): logger.error(f"Error downloading model {model_key}: {e}") queue.put_nowait({"status": "error", "message": str(e)}) - # Start the download in the background task = asyncio.create_task(background_download()) download_tasks[task_id] = DownloadTaskEntry(queue=queue, task=task) 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/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 750b6dff9..22464082f 100644 --- a/backend/app/routes/videos.py +++ b/backend/app/routes/videos.py @@ -11,7 +11,6 @@ from app.schemas.videos import ErrorResponse from app.logging.setup_logging import get_logger -# Initialize logger logger = get_logger(__name__) router = APIRouter() diff --git a/backend/app/schemas/album.py b/backend/app/schemas/album.py index 9a6c7b471..20232aed3 100644 --- a/backend/app/schemas/album.py +++ b/backend/app/schemas/album.py @@ -16,9 +16,7 @@ class Album(BaseModel): updated_at: Optional[str] = None -# ############################## # Request Handler -# ############################## class CreateAlbumRequest(BaseModel): @@ -88,9 +86,7 @@ def validate_image_ids(cls, value: List[str]) -> List[str]: return cleaned -# ############################## # Response Handler -# ############################## class GetAlbumsResponse(BaseModel): diff --git a/backend/app/schemas/share.py b/backend/app/schemas/share.py index 651e833fc..53280826e 100644 --- a/backend/app/schemas/share.py +++ b/backend/app/schemas/share.py @@ -4,9 +4,7 @@ from app.share.registry import PASSWORD_MAX_BYTES -# ############################## # Request Handler -# ############################## class CreateShareRequest(BaseModel): @@ -25,9 +23,7 @@ def check_password_length(cls, value: Optional[str]) -> Optional[str]: return value -# ############################## # Response Handler -# ############################## class ShareInterface(BaseModel): diff --git a/backend/app/utils/ONNX.py b/backend/app/utils/ONNX.py index ce5c87dd8..e5af53415 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 = 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..17e2158ee 100644 --- a/backend/app/utils/SigLIP.py +++ b/backend/app/utils/SigLIP.py @@ -13,20 +13,15 @@ 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 measures cosine ~0.984 against HF SiglipImageProcessor; + # exact parity would mean shipping transformers. SIGLIP2_MATCH_THRESHOLD + # was tuned here, so recalibration must use this function, 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) @@ -60,11 +55,8 @@ 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. with _tokenizer_lock: if _tokenizer is None or _tokenizer_key != current_key: tokenizer_path = get_model_path(current_key) diff --git a/backend/app/utils/YOLO.py b/backend/app/utils/YOLO.py index cf63d41dc..2172d2b9f 100644 --- a/backend/app/utils/YOLO.py +++ b/backend/app/utils/YOLO.py @@ -91,7 +91,6 @@ def YOLO_util_nms(boxes, scores, iou_threshold): - # Sort by score sorted_indices = np.argsort(scores)[::-1] keep_boxes = [] @@ -106,7 +105,6 @@ def YOLO_util_nms(boxes, scores, iou_threshold): # 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 @@ -254,18 +252,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 +274,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/extract_location_metadata.py b/backend/app/utils/extract_location_metadata.py index ba8370972..46f0dc546 100644 --- a/backend/app/utils/extract_location_metadata.py +++ b/backend/app/utils/extract_location_metadata.py @@ -14,7 +14,6 @@ from app.logging.setup_logging import get_logger -# Initialize logger logger = get_logger(__name__) @@ -185,7 +184,6 @@ def extract_datetime(self, metadata: Dict[str, Any]) -> Optional[datetime]: # Try ISO format first (handles timezone) if "T" in date_str: try: - # Remove timezone suffix for simpler parsing date_str_clean = ( date_str.replace("Z", "").split("+")[0].split("-") ) @@ -229,7 +227,6 @@ def extract_all( longitude = None captured_at = None - # Handle null/empty metadata if not metadata_json or metadata_json == "null": return None, None, None @@ -240,10 +237,8 @@ def extract_all( metadata = json.loads(metadata_json) - # Extract GPS coordinates latitude, longitude = self.extract_gps_coordinates(metadata) - # Extract datetime captured_at = self.extract_datetime(metadata) except json.JSONDecodeError as e: diff --git a/backend/app/utils/face_clusters.py b/backend/app/utils/face_clusters.py index f6965d67e..3aeb750c7 100644 --- a/backend/app/utils/face_clusters.py +++ b/backend/app/utils/face_clusters.py @@ -41,7 +41,6 @@ ) from app.logging.setup_logging import get_logger -# Initialize logger logger = get_logger(__name__) @@ -149,16 +148,13 @@ def cluster_util_face_clusters_sync(force_full_reclustering: bool = False): "face_image_base64": None, # Will be updated later } - # Convert to list for batch insert cluster_list = list(unique_clusters.values()) # Perform all database operations within a single transaction with get_db_connection() as conn: cursor = conn.cursor() - # Clear old clusters first db_delete_all_clusters(cursor) - # Insert the new clusters into database first db_insert_clusters_batch(cluster_list, cursor) # Now update face cluster assignments (foreign keys will be valid) @@ -168,7 +164,6 @@ def cluster_util_face_clusters_sync(force_full_reclustering: bool = False): for cluster_id in unique_clusters.keys(): face_image_base64 = _generate_cluster_face_image(cluster_id, cursor) if face_image_base64: - # Update the cluster with the generated face image success = _update_cluster_face_image( cluster_id, face_image_base64, cursor ) @@ -203,11 +198,9 @@ def _validate_embedding(embedding: NDArray, min_norm: float = 1e-6) -> bool: Returns: True if embedding is valid, False otherwise """ - # Check for NaN or infinite values if not np.isfinite(embedding).all(): return False - # Check if embedding is effectively zero (too small norm) norm = np.linalg.norm(embedding) if norm < min_norm: return False @@ -247,13 +240,11 @@ def cluster_util_cluster_all_face_embeddings( Returns: List of ClusterResult objects containing face_id, embedding, cluster_uuid, and cluster_name """ - # Get all faces with their existing cluster names faces_data = db_get_all_faces_with_cluster_names() if not faces_data: return [], 0 - # Extract embeddings and face IDs with validation embeddings = [] face_ids = [] image_ids = [] @@ -286,10 +277,8 @@ def cluster_util_cluster_all_face_embeddings( logger.info(f"Total valid faces to cluster: {len(face_ids)}") - # Convert to numpy array for DBSCAN embeddings_array = np.array(embeddings) - # Calculate pairwise distances with similarity threshold distances = cosine_distances(embeddings_array) # Guard against NaN distances (shouldn't happen after validation, but double-check) @@ -352,17 +341,14 @@ def cluster_util_cluster_all_face_embeddings( } ) - # Generate cluster UUIDs and determine cluster names results = [] for cluster_label, faces_in_cluster in clusters.items(): - # Generate unique UUID for this cluster cluster_uuid = str(uuid.uuid4()) # Determine cluster name using majority voting cluster_name = _determine_cluster_name(faces_in_cluster) - # Create ClusterResult objects for all faces in this cluster for face in faces_in_cluster: result = ClusterResult( face_id=face["face_id"], @@ -411,18 +397,15 @@ def cluster_util_assign_cluster_to_faces_without_clusterId( Returns: List of face-cluster mappings ready for batch update """ - # Get faces without cluster assignments unassigned_faces = db_get_faces_unassigned_clusters() if not unassigned_faces: return [], 0 - # Get cluster mean embeddings cluster_means = db_get_cluster_mean_embeddings() if not cluster_means: return [], 0 - # Prepare data for nearest neighbor assignment with validation cluster_ids = [] mean_embeddings = [] invalid_clusters = 0 @@ -430,7 +413,6 @@ def cluster_util_assign_cluster_to_faces_without_clusterId( for cluster_data in cluster_means: mean_emb = cluster_data["mean_embedding"] - # Validate cluster mean embedding if _validate_embedding(mean_emb): cluster_ids.append(cluster_data["cluster_id"]) mean_embeddings.append(mean_emb) @@ -452,7 +434,6 @@ def cluster_util_assign_cluster_to_faces_without_clusterId( # (cluster_id, image_id) pairs already taken; a photo's faces are distinct people occupied_pairs = db_get_cluster_image_pairs() - # Prepare batch update data face_cluster_mappings = [] skipped_invalid = 0 @@ -460,13 +441,11 @@ def cluster_util_assign_cluster_to_faces_without_clusterId( face_id = face["face_id"] face_embedding = face["embeddings"] - # Validate face embedding if not _validate_embedding(face_embedding): skipped_invalid += 1 logger.warning(f"Skipping face_id {face_id} with invalid embedding") continue - # Calculate cosine distances to all cluster means distances = _calculate_cosine_distances(face_embedding, mean_embeddings_array) # Guard against NaN distances @@ -581,7 +560,6 @@ def _merge_similar_clusters( if len(cluster_map) <= 1: return results # Nothing to merge - # Calculate mean embedding for each cluster with validation cluster_means = {} invalid_clusters = [] @@ -589,7 +567,6 @@ def _merge_similar_clusters( embeddings = np.array([face.embedding for face in cluster_faces]) mean_embedding = np.mean(embeddings, axis=0) - # Validate cluster mean if _validate_embedding(mean_embedding): cluster_means[cluster_uuid] = mean_embedding else: @@ -598,7 +575,6 @@ def _merge_similar_clusters( f"Cluster {cluster_uuid} has invalid mean embedding, excluding from merge" ) - # Remove invalid clusters from consideration for invalid_uuid in invalid_clusters: cluster_map.pop(invalid_uuid, None) @@ -618,7 +594,6 @@ def _merge_similar_clusters( if uuid2 in merge_mapping: continue # Already merged - # Calculate similarity between cluster means emb1 = cluster_means[uuid1].reshape(1, -1) emb2 = cluster_means[uuid2].reshape(1, -1) @@ -638,7 +613,6 @@ def _merge_similar_clusters( f"Merging cluster {uuid2} into {uuid1} (similarity: {similarity:.3f})" ) - # Apply merges if merge_mapping: # Resolve transitive merges (follow chain to ultimate target) def resolve_final_cluster(uuid): @@ -649,7 +623,6 @@ def resolve_final_cluster(uuid): current = merge_mapping[current] return current - # Build merged results with resolved cluster UUIDs merged_results = [] for result in results: final_cluster = resolve_final_cluster(result.cluster_uuid) @@ -680,7 +653,6 @@ def resolve_final_cluster(uuid): else: final_cluster_names[cluster_uuid] = None - # Update all results with final cluster names for result in merged_results: result.cluster_name = final_cluster_names.get(result.cluster_uuid) @@ -723,7 +695,6 @@ def _calculate_cosine_distances( # Calculate cosine similarities (dot product of normalized vectors) cosine_similarities = np.dot(cluster_norms, face_norm) - # Convert to cosine distances (1 - similarity) cosine_distances = 1 - cosine_similarities # Guard against numerical errors producing values outside [0, 2] @@ -838,24 +809,20 @@ def _calculate_square_crop_bounds( width = int(bbox.get("width", 100)) height = int(bbox.get("height", 100)) - # Add padding around the face x_start = max(0, x - padding) y_start = max(0, y - padding) x_end = min(img_width, x + width + padding) y_end = min(img_height, y + height + padding) - # Calculate square crop dimensions centered on the face crop_width = x_end - x_start crop_height = y_end - y_start # Use the larger dimension to create a square crop square_size = max(crop_width, crop_height) - # Calculate center of the current crop center_x = x_start + crop_width // 2 center_y = y_start + crop_height // 2 - # Calculate square crop bounds centered on the face half_square = square_size // 2 square_x_start = max(0, center_x - half_square) square_y_start = max(0, center_y - half_square) @@ -873,7 +840,6 @@ def _calculate_square_crop_bounds( square_x_end = square_x_start + actual_square_size square_y_end = square_y_start + actual_square_size - # Ensure bounds are within image square_x_start = max(0, square_x_start) square_y_start = max(0, square_y_start) square_x_end = min(img_width, square_x_end) @@ -902,7 +868,6 @@ def _crop_and_resize_face( # Crop the square region face_crop = img[y_start:y_end, x_start:x_end] - # Check if crop is valid if face_crop.size == 0: return None @@ -948,27 +913,22 @@ def _generate_cluster_face_image( Base64 encoded face image string, or None if generation fails """ try: - # Get face data from database face_data = _get_cluster_face_data(cluster_uuid, cursor) if not face_data: return None image_path, bbox = face_data - # Load the image img = cv2.imread(image_path) if img is None: return None - # Calculate square crop bounds crop_bounds = _calculate_square_crop_bounds(bbox, img.shape) - # Crop and resize the face face_crop = _crop_and_resize_face(img, crop_bounds) if face_crop is None: return None - # Encode to base64 return _encode_image_to_base64(face_crop) except Exception as e: @@ -986,7 +946,6 @@ def _determine_cluster_name(faces_in_cluster: List[Dict]) -> Optional[str]: Returns: Most common non-null cluster name, or None if no named clusters exist """ - # Extract non-null cluster names existing_names = [ face["existing_cluster_name"] for face in faces_in_cluster diff --git a/backend/app/utils/folders.py b/backend/app/utils/folders.py index ec014f479..8b768462e 100644 --- a/backend/app/utils/folders.py +++ b/backend/app/utils/folders.py @@ -27,7 +27,6 @@ def folder_util_add_folder_tree( for dirpath, dirnames, _ in os.walk(root_path, topdown=True): dirpath = os.path.abspath(dirpath) - # Generate a UUID for this folder this_folder_id = str(uuid.uuid4()) # Determine parent ID for the map (not for initial insert) @@ -57,7 +56,6 @@ def folder_util_add_folder_tree( ) ) - # Insert all folders in a single database transaction db_insert_folders_batch(folders_data) return folder_map[root_path][0], folder_map @@ -119,7 +117,6 @@ def folder_util_delete_obsolete_folders( if not folders_to_delete: return 0, [] - # Get the folder IDs for the folders to delete folder_ids_to_delete = [ folder_id for folder_id, folder_path in db_child_folders @@ -154,7 +151,6 @@ def folder_util_add_multiple_folder_trees( for folder_path in folders_to_add: try: - # Add each new folder tree (including its subdirectories) root_folder_id, folder_map = folder_util_add_folder_tree( root_path=folder_path, parent_folder_id=parent_folder_id, @@ -162,7 +158,6 @@ def folder_util_add_multiple_folder_trees( taggingCompleted=False, ) - # Update parent IDs for the new folder tree db_update_parent_ids_for_subtree(folder_path, folder_map) # Add all folders from the folder_map as (folder_id, folder_path) tuples 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..f0a78420d 100644 --- a/backend/app/utils/image_metadata.py +++ b/backend/app/utils/image_metadata.py @@ -11,7 +11,6 @@ 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}") @@ -26,7 +25,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) diff --git a/backend/app/utils/images.py b/backend/app/utils/images.py index 0eafb5fdd..423795140 100644 --- a/backend/app/utils/images.py +++ b/backend/app/utils/images.py @@ -35,7 +35,6 @@ logger = get_logger(__name__) -# GPS EXIF tag constant GPS_INFO_TAG = 34853 logger = logging.getLogger(__name__) @@ -51,7 +50,6 @@ def image_util_process_folder_images(folder_data: List[Tuple[str, int, bool]]) - bool: True if all folders processed successfully, False otherwise """ try: - # Ensure thumbnail directory exists os.makedirs(THUMBNAIL_IMAGES_PATH, exist_ok=True) all_image_records = [] @@ -64,22 +62,17 @@ def image_util_process_folder_images(folder_data: List[Tuple[str, int, bool]]) - [folder_id for _, folder_id, _ in folder_data] ) - # Process each folder in the provided data for folder_path, folder_id, recursive in folder_data: try: - # Add folder ID to list for obsolete image cleanup all_folder_ids.append(folder_id) - # Step 1: Get all image files from current folder image_files = image_util_get_images_from_folder(folder_path, recursive) if not image_files: continue # No images in this folder, continue to next - # Step 2: Create folder path mapping for this folder folder_path_to_id = {os.path.abspath(folder_path): folder_id} - # Step 3: Prepare image records for this folder folder_image_records = image_util_prepare_image_records( image_files, folder_path_to_id, known_state ) @@ -89,11 +82,9 @@ def image_util_process_folder_images(folder_data: List[Tuple[str, int, bool]]) - logger.error(f"Error processing folder {folder_path}: {e}") continue # Continue with other folders even if one fails - # Step 4: Remove obsolete images that no longer exist in filesystem if all_folder_ids: image_util_remove_obsolete_images(all_folder_ids) - # Step 5: Bulk insert all new records if any exist if all_image_records: return db_bulk_insert_images(all_image_records) @@ -106,12 +97,10 @@ def image_util_process_folder_images(folder_data: List[Tuple[str, int, bool]]) - def image_util_process_untagged_images() -> bool: """Process all untagged images in folders with AI tagging enabled.""" try: - # Step 1: Get all untagged images and whose corresponding folder has AI tagging enabled untagged_images = db_get_untagged_images() if not untagged_images: return True # No untagged images to process - # Step 2: Process each untagged image image_util_classify_and_face_detect_images(untagged_images) return True @@ -187,13 +176,8 @@ def image_util_process_unembedded_images() -> None: embedded_count += len(good_arrays) if good_ids: - # Only mark images that actually got an embedding row. - # Corrupt images stay isEmbedded=False and get retried on - # the next pass -- unlike YOLO/FaceNet inference, preprocessing - # is a cheap check (PIL failing to open/decode), so the retry - # cost is low, and a file that becomes readable later (a - # transient lock, a restored backup) eventually gets embedded - # instead of being permanently excluded from semantic search. + # The rest stay isEmbedded=False and retry next pass -- cheap, + # and a file readable later is not lost to search for good. db_mark_images_embedded(good_ids) elapsed = time.time() - start_time @@ -220,28 +204,21 @@ def image_util_classify_and_face_detect_images( image_path = image["path"] image_id = image["id"] - # Step 1: Get classes classes = object_classifier.get_classes(image_path) - # Step 2: Insert class-image pairs if classes were detected if len(classes) > 0: - # Create image-class pairs image_class_pairs = [(image_id, class_id) for class_id in classes] logger.debug(f"Image-class pairs: {image_class_pairs}") - # Insert the pairs into the database db_insert_image_classes_batch(image_class_pairs) - # Step 3: Detect faces if "person" class is present if classes and 0 in classes: result = face_detector.detect_faces(image_id, image_path) if result: total_faces_skipped += result.get("faces_skipped", 0) - # Step 4: Update the image status in the database db_update_image_tagged_status(image_id, True) finally: - # Ensure resources are cleaned up object_classifier.close() face_detector.close() @@ -324,7 +301,6 @@ def image_util_prepare_image_records( os.path.join(THUMBNAIL_IMAGES_PATH, thumbnail_name) ) - # Generate thumbnail if image_util_generate_thumbnail(image_path, thumbnail_path): metadata = image_util_extract_metadata(image_path) logger.debug(f"Extracted metadata for {image_path}: {metadata}") @@ -352,9 +328,8 @@ def image_util_prepare_image_records( ) # Continue without GPS - don't fail the upload - # Build image record with GPS data - # ALWAYS include latitude, longitude, captured_at (even if None) - # to satisfy SQL INSERT statement named parameters + # latitude, longitude and captured_at are always present, None + # included: the INSERT binds them by name and fails if one is absent. image_record = { "id": image_id, "path": image_path, @@ -422,7 +397,6 @@ def image_util_generate_thumbnail( with Image.open(image_path) as img: img.thumbnail(size) - # Convert to RGB if the image has an alpha channel or is not RGB if img.mode in ("RGBA", "P"): img = img.convert("RGB") @@ -509,7 +483,6 @@ def image_util_find_folder_id_for_image( def image_util_is_valid_image(file_path: str) -> bool: """Check if the file is a valid image with allowed extensions.""" - # Check file extension first allowed_extensions = {".jpg", ".jpeg", ".png"} file_extension = Path(file_path).suffix.lower() diff --git a/backend/app/utils/memory_curator.py b/backend/app/utils/memory_curator.py index e7befbca3..41cbcad3f 100644 --- a/backend/app/utils/memory_curator.py +++ b/backend/app/utils/memory_curator.py @@ -97,18 +97,14 @@ EVENT_GAP_HOURS = 36.0 EVENT_MIN_IMAGES = 6 -# A label counts for an image when it is among the strongest few that image -# matched, and the image ranks in the upper half of everything that label -# matched. Ranks, not scores: SigLIP2 puts "mehndi" at 0.78 and "holi" at -# 0.0003 on the same library, so no absolute cut can serve both. +# Ranks, not scores: SigLIP2 puts "mehndi" at 0.78 and "holi" at 0.0003 on the +# same library, so no absolute cut can serve both. EVENT_LABEL_TOP_N = 2 EVENT_LABEL_PERCENTILE = 0.50 -# Visual cohesion is the real discriminator, but only as a margin above what -# this library already scores. SigLIP2 embeddings occupy a narrow cone: a -# random handful of unrelated photos scores ~0.58 mean pairwise cosine, so an -# absolute gate anywhere near that rejects nothing at all. Real occurrences -# measured +0.31 to +0.35 above baseline; random groups measure +0.00. +# A margin above this library's own baseline, not an absolute gate: SigLIP2 +# embeddings sit in a narrow cone where unrelated photos already score ~0.58. +# Real occurrences measure +0.31 to +0.35 above baseline; random groups +0.00. EVENT_COHESION_MARGIN = 0.15 # Enough to characterize the library without loading every embedding. @@ -135,10 +131,8 @@ PHOTOS_PER_VIDEO = 9 MAX_VIDEOS_PER_MEMORY = 3 -# Per clip, and across the whole memory. The budget matters as much as the -# count: three clips at the per-clip limit would outlast the photos between -# them. 15s covers most of a real library; the long ones are recordings, not -# moments. +# Per clip and per memory: three clips at the per-clip limit would outlast the +# photos between them. Anything longer than 15s is a recording, not a moment. MAX_VIDEO_SECONDS = 15.0 MAX_VIDEO_SECONDS_PER_MEMORY = 30.0 @@ -149,10 +143,8 @@ RECENT_USE_WINDOW_DAYS = 30 RECENT_USE_PENALTY = 0.35 -# Stand-ins for a memory nothing recognised. A bare date is a poor title for -# something whose whole job is to invite a second look, and there is no -# captioning model here to write a real one. The date is not lost: it moves to -# the subtitle, which the cards and the story viewer both render. +# Stand-ins for a memory nothing recognised -- a bare date is a poor invitation +# to look again, and there is no captioning model here. The date moves to the subtitle. GENERIC_TITLES_ONE_DAY = ( "Remember this day?", "Revisit this day", @@ -473,9 +465,7 @@ def _select_videos( return [] -# ############################## # Trigger 1: anniversary -# ############################## def _curate_anniversaries(context: _CurationContext) -> int: @@ -538,9 +528,7 @@ def _curate_anniversaries(context: _CurationContext) -> int: return generated -# ############################## # Trigger 2: import event -# ############################## def segment_by_time_and_place( @@ -628,10 +616,8 @@ def _curate_import_events(context: _CurationContext) -> int: dedupe_key = f"import:{start.date()}..{end.date()}" - # Name it after what the AI recognised, when it recognised - # anything; otherwise a stand-in, with the dates as the subtitle. # Deferred until the set is final, so a photo trimmed for not - # belonging cannot name the rest. + # belonging cannot end up naming the rest. def name_from_labels( selected_ids: List[str], dedupe_key: str = dedupe_key, @@ -669,9 +655,7 @@ def name_from_labels( return generated -# ############################## # Trigger 3: semantic event -# ############################## def group_event_occurrences( @@ -892,9 +876,7 @@ def _semantic_surface_date(reference: date, event_start: datetime) -> str: return reference.isoformat() -# ############################## # Entry point -# ############################## def memory_curator_rescore(memory_ids: Sequence[str]) -> int: @@ -993,10 +975,8 @@ def memory_curator_run( logger.info(f"Curating memories for {run_date} (trigger={trigger}, force={force})") try: - # Before anything is built: drop memories the library has outgrown. - # A re-sync that corrects capture dates leaves them stranded, and - # their photos are candidates again once they are gone. Housekeeping, - # so failing it must not cost the run the memories it came to make. + # A re-sync correcting capture dates strands old memories, and frees their + # photos once dropped. Housekeeping, so a failure must not fail the run. try: stale = db_delete_stale_memories() if stale: diff --git a/backend/app/utils/memory_monitor.py b/backend/app/utils/memory_monitor.py index c60c4a159..90592280b 100644 --- a/backend/app/utils/memory_monitor.py +++ b/backend/app/utils/memory_monitor.py @@ -22,14 +22,12 @@ 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() diff --git a/backend/app/utils/memory_scoring.py b/backend/app/utils/memory_scoring.py index 03f3018cd..c53f81576 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. +# BOTH must hold. Visual similarity alone collapses a daily coffee or a revisited +# viewpoint into one survivor -- near-identical, yet genuinely distinct photos. 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/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/app/utils/takeout_sidecar.py b/backend/app/utils/takeout_sidecar.py index b0930c3fb..9690d7313 100644 --- a/backend/app/utils/takeout_sidecar.py +++ b/backend/app/utils/takeout_sidecar.py @@ -27,10 +27,9 @@ ALBUM_METADATA_KEYS = frozenset({"entries", "albumData"}) -# The last directory listing, reused across its images. A sidecar lookup runs -# for every photo missing EXIF GPS, so scanning per photo made an N-file folder -# cost N scans of N entries. The stamp is read as one tuple, so a concurrent -# write can only cost a rescan, never a wrong answer. +# Reused across a directory's images: a lookup runs per photo missing EXIF GPS, +# so scanning each time cost N scans of N entries. Read as one tuple, so a +# concurrent write costs a rescan, never a wrong answer. _LISTING_CACHE: Tuple[Optional[str], float, Tuple[str, ...]] = (None, 0.0, ()) diff --git a/backend/app/utils/videos.py b/backend/app/utils/videos.py index 1c209636d..9a9223b43 100644 --- a/backend/app/utils/videos.py +++ b/backend/app/utils/videos.py @@ -55,7 +55,6 @@ def video_util_process_folder_videos(folder_data: List[Tuple[str, int, bool]]) - bool: True if all folders processed successfully, False otherwise """ try: - # Ensure thumbnail directory exists os.makedirs(THUMBNAIL_IMAGES_PATH, exist_ok=True) all_video_records: List[VideoRecord] = [] @@ -455,9 +454,7 @@ def video_util_remove_obsolete_videos(folder_id_list: List[int]) -> int: return len(obsolete_videos) -# ============================================================================ -# KEYFRAME SAMPLING - AI tagging without per-frame inference -# ============================================================================ +# Keyframe sampling: AI tagging without per-frame inference. def video_util_frame_directory(video_id: str) -> str: diff --git a/backend/main.py b/backend/main.py index a3e028663..0828daab8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -93,9 +93,8 @@ 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) # Self-gating no-ops unless something is missing/stale (fresh install, # checkpoint swap, edited seed). Single-worker executor runs them in diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ccf14b4ae..649c77a04 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -20,7 +20,6 @@ def setup_before_all_tests(): print("\n=== Running manual setup fixture ===") - # Set test environment os.environ["TEST_MODE"] = "true" # Create all database tables in the same order as main.py diff --git a/backend/tests/test_albums.py b/backend/tests/test_albums.py index 3f1a524ff..48bc1b610 100644 --- a/backend/tests/test_albums.py +++ b/backend/tests/test_albums.py @@ -18,9 +18,7 @@ client = TestClient(app) -# ############################## # Pytest Fixtures -# ############################## def album_row( @@ -87,9 +85,7 @@ def mock_db_locked_album(): } -# ############################## # Test Classes -# ############################## class TestAlbumRoutes: @@ -127,7 +123,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_albums_db.py b/backend/tests/test_albums_db.py index 20ebae5aa..ed3846ac9 100644 --- a/backend/tests/test_albums_db.py +++ b/backend/tests/test_albums_db.py @@ -28,9 +28,7 @@ ) from app.database.images import db_create_images_table -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture(scope="function") @@ -100,9 +98,7 @@ def stored_hash(db_path: str, album_id: str) -> Optional[str]: return row[0] -# ############################## # Table creation -# ############################## class TestAlbumTables: @@ -179,9 +175,7 @@ def test_closes_the_connection_when_create_fails(self, create_table): conn.close.assert_called_once() -# ############################## # Album CRUD -# ############################## class TestAlbumCrud: @@ -232,9 +226,7 @@ def test_delete_removes_the_row(self, test_db): assert db_get_album("album-1") is None -# ############################## # Album images -# ############################## class TestAlbumImages: @@ -492,9 +484,7 @@ def test_a_duplicate_name_is_rejected(self, test_db): assert db_get_album("album-2") is None -# ############################## # Password handling -# ############################## class TestAlbumPassword: @@ -537,9 +527,7 @@ def test_update_without_password_keeps_the_existing_one(self, test_db): assert verify_album_password("album-1", "oldpass") is True -# ############################## # Cover image -# ############################## class TestAlbumCoverPath: diff --git a/backend/tests/test_connection.py b/backend/tests/test_connection.py index 1b1b22f53..54e5d7523 100644 --- a/backend/tests/test_connection.py +++ b/backend/tests/test_connection.py @@ -7,9 +7,7 @@ from app.database.connection import get_db_connection -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture(scope="function") @@ -35,9 +33,7 @@ def read_names(db_path: str) -> List[Tuple[str]]: conn.close() -# ############################## # Transaction handling -# ############################## class TestGetDbConnection: @@ -69,9 +65,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_face_clusters.py b/backend/tests/test_face_clusters.py index 7991585ae..5c1bd50ea 100644 --- a/backend/tests/test_face_clusters.py +++ b/backend/tests/test_face_clusters.py @@ -22,9 +22,7 @@ client = TestClient(app) -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture @@ -93,17 +91,13 @@ def sample_cluster_images(): ] -# ############################## # Test Classes -# ############################## class TestFaceClustersAPI: """Test class for Face Clusters API endpoints.""" - # ============================================================================ # PUT /face_clusters/{cluster_id} - Rename Cluster Tests - # ============================================================================ @patch("app.routes.face_clusters.db_update_cluster") @patch("app.routes.face_clusters.db_get_cluster_by_id") @@ -229,9 +223,7 @@ def test_rename_cluster_name_whitespace_trimming( cluster_name="John Doe", # Should be trimmed ) - # ============================================================================ # GET /face_clusters/ - Get All Clusters Tests - # ============================================================================ @patch("app.routes.face_clusters.db_get_all_clusters_with_face_counts") def test_get_all_clusters_success( @@ -248,7 +240,6 @@ def test_get_all_clusters_success( assert "Successfully retrieved 3 cluster(s)" in data["message"] assert len(data["data"]["clusters"]) == 3 - # Check first cluster details first_cluster = data["data"]["clusters"][0] assert first_cluster["cluster_id"] == "cluster_1" assert first_cluster["cluster_name"] == "John Doe" @@ -306,9 +297,7 @@ def test_get_all_clusters_response_structure(self, sample_clusters_with_counts): assert isinstance(cluster["cluster_name"], str) assert isinstance(cluster["face_count"], int) - # ============================================================================ # GET /face_clusters/{cluster_id}/images - Get Cluster Images Tests - # ============================================================================ @patch("app.routes.face_clusters.db_get_images_by_cluster_id") @patch("app.routes.face_clusters.db_get_cluster_by_id") @@ -334,7 +323,6 @@ def test_get_cluster_images_success( assert data["data"]["total_images"] == 2 assert len(data["data"]["images"]) == 2 - # Check first image details first_image = data["data"]["images"][0] assert first_image["id"] == "img_1" assert first_image["path"] == "/path/to/image1.jpg" @@ -390,9 +378,7 @@ def test_get_cluster_images_database_error(self, mock_get_cluster): assert data["detail"]["success"] is False assert data["detail"]["error"] == "Internal server error" - # ============================================================================ # Additional Edge Case Tests - # ============================================================================ def test_rename_cluster_missing_request_body(self): """Test rename cluster with missing request body.""" @@ -425,9 +411,7 @@ def test_unsupported_http_methods(self, method, endpoint): assert response.status_code == 405 -# ============================================================================ # Algorithmic Logic Tests -# ============================================================================ def generate_synthetic_embeddings( @@ -444,7 +428,6 @@ def generate_synthetic_embeddings( center = np.random.randn(dim) center = center / np.linalg.norm(center) - # Add points around center for _ in range(points_per_identity): noise = np.random.randn(dim) * noise_std point = center + noise @@ -476,7 +459,6 @@ class TestFaceClusteringAlgo: @patch("app.utils.face_clusters.db_get_all_faces_with_cluster_names") def test_folder_size_regression(self, mock_db_get): """Test 1: Folder-size regression (the original bug)""" - # Generate 20 embeddings (2 identities, 10 points each) identity_embs, identity_labels = generate_synthetic_embeddings( num_identities=2, points_per_identity=10 ) @@ -496,7 +478,6 @@ def test_folder_size_regression(self, mock_db_get): len(isolated_clusters) == 2 ), f"The folder-size bug is present: expected 2 clusters, got {len(isolated_clusters)} in isolated run" - # Verify points were assigned correctly (10 points per cluster) cluster_counts = {} for r in results_isolated: cluster_counts[r.cluster_uuid] = cluster_counts.get(r.cluster_uuid, 0) + 1 @@ -561,10 +542,8 @@ def test_estimate_eps_fallback(self): @patch("app.utils.face_clusters.db_get_all_faces_with_cluster_names") def test_adaptive_eps_clamping_regression(self, mock_db_get): """Test 4: Adaptive eps clamping under sparse datasets with singletons""" - # Create 9 embeddings: - # Identity A: 2 points (very close) - # Identity B: 2 points (very close) - # 5 Singleton points (completely random / orthogonal) + # 9 embeddings: two tight pairs (identities A and B) and 5 orthogonal + # singletons. dim = 512 np.random.seed(42) @@ -596,7 +575,6 @@ def test_adaptive_eps_clamping_regression(self, mock_db_get): all_embeddings = [pt_a1, pt_a2, pt_b1, pt_b2] + singletons - # Mock database call mock_db_get.return_value = [ {"face_id": i, "embeddings": emb, "cluster_name": None} for i, emb in enumerate(all_embeddings) @@ -713,9 +691,7 @@ def test_quality_gate(self): ) -# ############################## # Stale cluster cleanup (issue #1023) -# ############################## @pytest.fixture diff --git a/backend/tests/test_face_quality.py b/backend/tests/test_face_quality.py index 005c7cefd..45f1dcd8e 100644 --- a/backend/tests/test_face_quality.py +++ b/backend/tests/test_face_quality.py @@ -2,9 +2,7 @@ from app.utils.face_quality import face_passes_quality_gate -# ############################## # Helpers -# ############################## def sharp_gray(size: int = 50) -> np.ndarray: @@ -14,9 +12,7 @@ def sharp_gray(size: int = 50) -> np.ndarray: return img -# ############################## # Quality gate -# ############################## class TestFaceQualityGate: diff --git a/backend/tests/test_faces_db.py b/backend/tests/test_faces_db.py index 1dc21fb44..a767eb548 100644 --- a/backend/tests/test_faces_db.py +++ b/backend/tests/test_faces_db.py @@ -18,9 +18,7 @@ ) from app.database.face_clusters import db_create_clusters_table -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture(scope="function") @@ -68,9 +66,7 @@ def add_face( ) -# ############################## # Table creation -# ############################## class TestFacesTable: @@ -104,9 +100,7 @@ def test_closes_the_connection_when_create_fails(self): conn.close.assert_called_once() -# ############################## # Inserting embeddings -# ############################## class TestInsertFaceEmbeddings: @@ -138,9 +132,7 @@ def test_bbox_is_null_when_omitted(self, test_db): assert bbox_json is None -# ############################## # Reading faces -# ############################## class TestUnassignedFaces: @@ -177,9 +169,7 @@ def test_returns_empty_without_faces(self, test_db): assert db_get_all_faces_with_cluster_names() == [] -# ############################## # Cluster assignment -# ############################## class TestUpdateClusterIdsBatch: @@ -233,9 +223,7 @@ def test_caller_supplied_cursor_is_left_uncommitted(self, test_db): assert [f["face_id"] for f in db_get_faces_unassigned_clusters()] == [face_id] -# ############################## # Cluster mean embeddings -# ############################## class TestClusterMeanEmbeddings: diff --git a/backend/tests/test_folders.py b/backend/tests/test_folders.py index df21a288a..0bbd3633d 100644 --- a/backend/tests/test_folders.py +++ b/backend/tests/test_folders.py @@ -39,9 +39,7 @@ 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") @@ -56,10 +54,8 @@ 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 whole FK chain must resolve. db_create_YOLO_classes_table() db_create_folders_table() db_create_images_table() # db_get_all_folder_details LEFT JOINs it @@ -103,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) @@ -155,17 +150,13 @@ 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") @@ -202,7 +193,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() @@ -242,24 +232,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") @@ -409,9 +405,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): @@ -492,13 +486,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): @@ -593,9 +584,7 @@ def test_disable_ai_tagging_no_background_processing( 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): @@ -667,9 +656,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( @@ -687,7 +674,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" @@ -726,9 +712,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.""" @@ -776,9 +760,7 @@ 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: @@ -1246,9 +1228,7 @@ 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") @@ -1379,7 +1359,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_images_db.py b/backend/tests/test_images_db.py index b696e28b4..617b322dd 100644 --- a/backend/tests/test_images_db.py +++ b/backend/tests/test_images_db.py @@ -25,9 +25,7 @@ from app.database.yolo_mapping import db_create_YOLO_classes_table from app.database.semantic_labels import db_create_semantic_labels_table -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture(scope="function") @@ -110,9 +108,7 @@ def drop_object(db_path: str, kind: str, name: str) -> None: conn.close() -# ############################## # Table creation -# ############################## class TestCreateImagesTable: @@ -151,9 +147,7 @@ def test_adds_score_column_to_legacy_image_classes(self, test_db): assert "score" in columns -# ############################## # Bulk insert -# ############################## class TestBulkInsertImages: @@ -185,9 +179,7 @@ def test_foreign_key_violation_returns_false(self, test_db): assert db_get_all_images() == [] -# ############################## # Reading images -# ############################## class TestGetAllImages: @@ -257,9 +249,7 @@ def test_unembedded_lists_only_unembedded_in_ai_folders(self, folder, test_db): assert [img["id"] for img in db_get_unembedded_images()] == ["img-1"] -# ############################## # Tagging, classes and status -# ############################## class TestTaggedStatusAndClasses: @@ -298,9 +288,7 @@ def test_missing_image_returns_false(self, test_db): assert db_toggle_image_favourite_status("nope") is False -# ############################## # Folder queries and deletion -# ############################## class TestFolderQueriesAndDeletion: @@ -349,9 +337,7 @@ def test_delete_by_ids_cascades_to_image_classes(self, folder, test_db): assert remaining == 0 -# ############################## # Search and lookup by ids -# ############################## class TestSearchAndGetByIds: @@ -383,9 +369,7 @@ def test_get_images_by_ids_preserves_request_order(self, folder, test_db): assert [img["id"] for img in results] == ["img-3", "img-1"] -# ############################## # Marking embedded -# ############################## class TestMarkImagesEmbedded: @@ -403,9 +387,7 @@ def test_marks_images_embedded(self, folder, test_db): assert db_get_unembedded_images() == [] -# ############################## # Error handling -# ############################## class TestErrorHandling: diff --git a/backend/tests/test_memories_db.py b/backend/tests/test_memories_db.py index e7a9bf87f..36b657976 100644 --- a/backend/tests/test_memories_db.py +++ b/backend/tests/test_memories_db.py @@ -32,9 +32,7 @@ ) from app.database.yolo_mapping import db_create_YOLO_classes_table -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture(scope="function") @@ -114,9 +112,7 @@ def set_started_at(db_path: str, run_date: str, expression: str) -> None: conn.close() -# ############################## # Table creation -# ############################## class TestCreateMemoriesTable: @@ -168,9 +164,7 @@ def test_event_type_check_constraint_rejects_unknown_values( db_upsert_memory(make_memory("k", event_type=event_type), []) -# ############################## # Upsert -# ############################## class TestUpsertMemory: @@ -240,9 +234,7 @@ def test_missing_signals_read_back_as_none(self, images: List[str]): assert db_get_memory(memory_id)["signals"] is None -# ############################## # Cascade behaviour -# ############################## class TestCascades: @@ -284,9 +276,7 @@ def test_live_image_count_reflects_deletions(self, images: List[str]): assert stored["live_image_count"] == 1 -# ############################## # Marking and pruning -# ############################## class TestMarkAndPrune: @@ -335,9 +325,7 @@ def test_prune_marks_shrunken_memories_empty(self, images: List[str]): assert db_get_memory(keep)["status"] == "complete" -# ############################## # Stale memories -# ############################## def redate(db_path: str, image_id: str, captured_at: Optional[str]) -> None: @@ -441,9 +429,7 @@ def test_only_the_affected_memory_goes(self, test_db: str, images: List[str]): assert db_get_memory(intact) is not None -# ############################## # Listing and surfacing -# ############################## class TestListAndSurface: @@ -522,9 +508,7 @@ def test_unviewed_count_tracks_marking(self, images: List[str]): assert db_count_unviewed_memories("2026-07-26") == 1 -# ############################## # Dedupe keys and recent use -# ############################## class TestDedupeAndRecentUse: @@ -565,9 +549,7 @@ def test_recently_used_is_empty_without_memories(self, images: List[str]): assert db_get_recently_used_image_ids(30, "2026-07-26") == set() -# ############################## # Anniversary candidates -# ############################## class TestAnniversaryCandidates: @@ -586,9 +568,7 @@ def test_returns_capture_year_and_empty_for_no_month_days(self, images: List[str assert db_get_anniversary_candidates([], 2025) == [] -# ############################## # Runs -# ############################## class TestMemoryRuns: @@ -649,9 +629,7 @@ def test_reap_ignores_finished_runs(self, test_db: str): assert db_reap_stale_memory_runs(30) == 0 -# ############################## # Indexing gate -# ############################## class TestIndexingBusy: diff --git a/backend/tests/test_memories_route.py b/backend/tests/test_memories_route.py index c999362cc..e5529f4b4 100644 --- a/backend/tests/test_memories_route.py +++ b/backend/tests/test_memories_route.py @@ -10,9 +10,7 @@ from app.routes import memories as memories_route from app.schemas.user_preferences import MemoriesPreferences -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture @@ -115,9 +113,7 @@ def make_run(status: str) -> Dict[str, Any]: } -# ############################## # POST /memories/generate -# ############################## class TestGenerateMemories: @@ -367,9 +363,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 +448,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 +503,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 +547,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 +593,7 @@ def test_unknown_id_returns_404(self, client: TestClient): assert response.status_code == 404 -# ############################## # DELETE /memories/{memory_id} -# ############################## class TestDeleteMemory: @@ -623,9 +609,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_memory_curator.py b/backend/tests/test_memory_curator.py index 190ea9bdb..3ef5ddd04 100644 --- a/backend/tests/test_memory_curator.py +++ b/backend/tests/test_memory_curator.py @@ -17,17 +17,13 @@ T0 = datetime(2024, 7, 26, 10, 0, 0) REFERENCE = "2026-07-26" -# One shared direction stands in for a visually consistent set. Scattered sets -# use mutually orthogonal basis vectors: with N distinct directions the mean -# cosine to the centroid is 1/sqrt(N), so eight of them land well under the -# gate while two would not. +# One shared direction stands in for a consistent set. Orthogonal ones score a +# mean pairwise cosine of 0, well under any baseline, so the gate rejects them. COHERENT = np.eye(8, dtype=np.float32)[0] SCATTER_BASIS = np.eye(8, dtype=np.float32) -# What a random pair of photos in this library already scores, measured on the -# real one. Every cohesion test is a margin over it, so a run that reads no -# baseline at all judges nothing - which is why it is stubbed rather than left -# to whatever embeddings happen to be in the database. +# Measured on the real library. Every cohesion test is a margin over it, so it is +# stubbed rather than left to whatever embeddings the database happens to hold. LIBRARY_BASELINE = 0.58 @@ -47,9 +43,7 @@ def library_sample(pairwise: float = LIBRARY_BASELINE, count: int = 4) -> List[A return vectors -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture(autouse=True) @@ -217,9 +211,7 @@ def of_type(upserts: List[Dict[str, Any]], event_type: str) -> List[Dict[str, An return [u for u in upserts if u["memory"]["event_type"] == event_type] -# ############################## # Helpers -# ############################## class TestAnniversaryWindow: @@ -321,9 +313,7 @@ def test_ignores_delivery_only_preferences(self): assert baseline == changed -# ############################## # Trigger 1: anniversary -# ############################## class TestAnniversaryCuration: @@ -410,9 +400,7 @@ def flaky(memory, images, videos=()): assert len(seen) == 2 -# ############################## # Trigger 2: import event -# ############################## class TestSegmentByTimeAndPlace: @@ -538,9 +526,7 @@ def test_caps_the_number_of_import_memories(self): assert len(upserts) == memory_curator.MAX_IMPORT_MEMORIES -# ############################## # Trigger 3: semantic event -# ############################## def event_hits( @@ -799,9 +785,7 @@ def test_leap_day_event_falls_back_to_today(self): ) -# ############################## # Run orchestration -# ############################## class TestOutlierTrimming: @@ -1073,9 +1057,7 @@ def test_recently_used_is_refreshed_between_triggers(self): assert mocks["db_get_recently_used_image_ids"].call_count == 4 -# ############################## # Enablement -# ############################## class TestEnablement: diff --git a/backend/tests/test_memory_scoring.py b/backend/tests/test_memory_scoring.py index 6305af7c3..9948bfd67 100644 --- a/backend/tests/test_memory_scoring.py +++ b/backend/tests/test_memory_scoring.py @@ -61,9 +61,7 @@ def candidate( return {"id": image_id, "score": score, "captured_at": captured_at} -# ############################## # Haversine -# ############################## class TestHaversine: @@ -85,9 +83,7 @@ def test_is_symmetric(self): assert forward == pytest.approx(backward) -# ############################## # Weights -# ############################## class TestResolveWeights: @@ -140,9 +136,7 @@ def test_changes_with_version_and_weights(self): assert scoring_signature(MemoryScoringWeights(favourite=0.9), 1) != baseline -# ############################## # Signal normalization -# ############################## class TestComputeSignals: @@ -196,9 +190,7 @@ def test_zero_valued_signals_stay_available(self, signal): assert signal in available -# ############################## # Composite score -# ############################## class TestCompositeScore: @@ -279,9 +271,7 @@ def test_carries_signals_and_timestamps_through(self): assert "favourite" in ranked[0]["signals"] -# ############################## # Near-duplicate suppression -# ############################## class TestSuppressNearDuplicates: @@ -348,9 +338,7 @@ def test_empty_input(self): assert suppress_near_duplicates([], {}) == [] -# ############################## # Time spreading -# ############################## class TestSpreadOverTime: @@ -428,9 +416,7 @@ def test_non_positive_target_returns_nothing(self, target): assert spread_over_time([candidate("a")], target) == [] -# ############################## # Memory-level score -# ############################## class TestAggregateMemoryScore: @@ -461,9 +447,7 @@ def test_uses_the_best_images_not_the_average(self): assert aggregate_memory_score(with_tail) >= aggregate_memory_score(without_tail) -# ############################## # Home detection -# ############################## class TestDetectHomeLocation: @@ -496,9 +480,7 @@ def test_applies_the_minimum_image_threshold(self): ) -# ############################## # Timestamp parsing -# ############################## class TestParseCapturedAt: @@ -518,9 +500,7 @@ def test_parses_or_returns_none(self, value, expected): assert parse_captured_at(value) == expected -# ############################## # Cohesion -# ############################## CONE_HALF_ANGLE = 1.1 # radians; wide enough to separate, narrow enough to @@ -667,9 +647,7 @@ def test_too_few_embeddings_to_judge_leaves_the_group_alone(self): assert len(trim_incoherent(candidates, sparse, min_keep=5)) == len(candidates) -# ############################## # Videos in a memory -# ############################## class TestVideoSignalAvailability: diff --git a/backend/tests/test_memory_signals_db.py b/backend/tests/test_memory_signals_db.py index 9a712d976..698bba719 100644 --- a/backend/tests/test_memory_signals_db.py +++ b/backend/tests/test_memory_signals_db.py @@ -52,9 +52,7 @@ ) from app.database.yolo_mapping import db_create_YOLO_classes_table -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture(scope="function") @@ -179,9 +177,7 @@ def add_geotagged( conn.close() -# ############################## # Scoring signal collection -# ############################## class TestScoringSignals: @@ -292,9 +288,7 @@ def test_handles_more_ids_than_the_chunk_size(self, test_db: str): assert len(db_get_scoring_signals(bulk_ids)) == 600 -# ############################## # GPS histogram -# ############################## class TestGpsHistogram: @@ -334,9 +328,7 @@ def test_cell_centre_returns_none_for_an_empty_cell(self, images: List[str]): assert db_get_gps_cell_centre(0.0, 0.0, precision=1) is None -# ############################## # Event labels -# ############################## class TestEventLabels: @@ -515,9 +507,7 @@ def test_top_label_is_none_without_matches(self, images: List[str], ids: List[st assert db_get_top_memory_label(ids, 5, 0.15, 0.15, 2) is None -# ############################## # Tagging completion lifecycle -# ############################## @pytest.fixture @@ -691,9 +681,7 @@ def test_sync_clears_the_gate_too(self, ai_folder: str): assert db_is_indexing_busy() is False -# ############################## # Rescoring after a cluster rename -# ############################## @pytest.fixture @@ -860,9 +848,7 @@ def test_rename_route_stays_quiet_when_the_rescore_succeeds(self): error.assert_not_called() -# ############################## # Period lookup and embeddings -# ############################## class TestPeriodAndEmbeddings: @@ -920,9 +906,7 @@ def test_embeddings_empty_input(self, test_db: str): assert db_get_embeddings_for_image_ids([], "v1") == {} -# ############################## # Folder pipeline hook -# ############################## AI_PIPELINE_STEPS = ( diff --git a/backend/tests/test_metadata.py b/backend/tests/test_metadata.py index cc08d9d76..3513913d7 100644 --- a/backend/tests/test_metadata.py +++ b/backend/tests/test_metadata.py @@ -13,9 +13,7 @@ db_update_metadata, ) -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture(scope="function") @@ -70,9 +68,7 @@ def __getattr__(self, name: str) -> Any: return getattr(self._conn, name) -# ############################## # Table creation -# ############################## class TestCreateMetadataTable: @@ -98,9 +94,7 @@ def test_survives_a_connection_failure(self): db_create_metadata_table() -# ############################## # Reading metadata -# ############################## class TestGetMetadata: @@ -117,9 +111,7 @@ def test_returns_none_for_invalid_json(self, test_db): assert db_get_metadata() is None -# ############################## # Updating metadata -# ############################## class TestUpdateMetadata: diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index e34ec0b15..6bd940cbf 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -28,9 +28,7 @@ client = TestClient(app) -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture @@ -99,10 +97,8 @@ def mock_model_registry(): @pytest.fixture def mock_model_registry_with_placeholder(mock_model_registry): - # Mirrors the real registry's not-yet-uploaded entries (e.g. siglip2_large_vision), - # which /status must exclude. Covered as three independent cases so a - # regression from `or` to `and` in the route's placeholder check would - # still be caught. + # Mirrors the registry's not-yet-uploaded entries, which /status must exclude. + # Three independent cases, so an `or` -> `and` regression is still caught. registry = dict(mock_model_registry) registry["siglip2_large_vision"] = { "filename": "SigLIP2_Large_Vision.onnx", @@ -254,9 +250,7 @@ def download_facenet_response(mock_model_registry): return client.post("/models/download/facenet") -# ############################## # Test Classes — endpoints not covered upstream -# ############################## class TestModelStatus: @@ -810,9 +804,7 @@ def test_all_valid_model_keys_return_200(self, model_key, mock_model_registry): assert response.status_code == 200 -# ############################## # Test Classes — from upstream (verbatim) -# ############################## class TestModelsAPI: diff --git a/backend/tests/test_onnx_session_base.py b/backend/tests/test_onnx_session_base.py index 9a154e235..e975242a7 100644 --- a/backend/tests/test_onnx_session_base.py +++ b/backend/tests/test_onnx_session_base.py @@ -32,10 +32,8 @@ def _mock_ort_session(input_names: list[str]) -> MagicMock: @pytest.fixture(autouse=True) def _clean_registry(): yield - # Belt-and-suspenders: don't let a failed assertion mid-test leak a - # registered session into other tests sharing this real model key. A - # fresh SigLIP2Text(...).close() is a no-op here (its _session_registered - # starts False), so decrement the registry directly instead. + # Stops a failed assertion leaking a registered session into other tests. + # A fresh close() is a no-op here, so decrement the registry directly. while get_active_session_count(MODEL_KEY) > 0: mark_model_session_inactive(MODEL_KEY) diff --git a/backend/tests/test_semantic_search_route.py b/backend/tests/test_semantic_search_route.py index 9cb97f642..c4bb2b472 100644 --- a/backend/tests/test_semantic_search_route.py +++ b/backend/tests/test_semantic_search_route.py @@ -135,13 +135,8 @@ 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. + # Realistic dot magnitudes, not saturated toy vectors. img_high has the + # *lower* score 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 +152,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 ] diff --git a/backend/tests/test_user_preferences.py b/backend/tests/test_user_preferences.py index 6ff03cb54..c5638e59d 100644 --- a/backend/tests/test_user_preferences.py +++ b/backend/tests/test_user_preferences.py @@ -9,9 +9,7 @@ client = TestClient(app) -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture @@ -47,9 +45,7 @@ def empty_metadata(): return {} -# ############################## # Test Classes -# ############################## class TestUserPreferencesAPI: @@ -567,9 +563,7 @@ def test_partial_update_allows_none(self): ) -# ############################## # Memories preferences -# ############################## class TestMemoriesPreferences: 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_video_frames.py b/backend/tests/test_video_frames.py index 93a6131bc..34f99f0d4 100644 --- a/backend/tests/test_video_frames.py +++ b/backend/tests/test_video_frames.py @@ -36,9 +36,7 @@ video_util_sample_frame_timestamps, ) -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture(scope="function") @@ -165,9 +163,7 @@ def insert_frames(video_id, count, frames_dir=None): return records -# ############################## # Sampling strategy -# ############################## class TestSampleFrameTimestamps: @@ -201,9 +197,7 @@ def test_respects_the_cap_exactly(self): assert len(video_util_sample_frame_timestamps(100.0, 1.0, 10)) == 10 -# ############################## # Aggregating frames into video tags -# ############################## class TestAggregateFrameClasses: @@ -229,9 +223,7 @@ def test_no_frames_means_no_tags(self): assert video_util_aggregate_frame_classes([], 2) == [] -# ############################## # Frame extraction -# ############################## class TestExtractVideoFrames: @@ -268,9 +260,7 @@ def test_undecodable_file_yields_no_frames(self, temp_media_dir, frames_dir): assert video_util_extract_video_frames("vid-1", broken, 5.0) == [] -# ############################## # Database round-trips -# ############################## class TestVideoFrameDatabase: @@ -360,9 +350,7 @@ def test_scoring_signature_gates_rework(self, video_id): assert db_get_videos_needing_scoring("m1", "sig-2", 10) == [video_id] -# ############################## # Purging the frame cache -# ############################## class TestPurgeFrameCache: @@ -389,9 +377,7 @@ def test_purging_an_empty_cache_is_harmless(self, test_db, frames_dir): assert video_util_purge_frame_cache() == 0 -# ############################## # Routes -# ############################## class TestVideoTagRoutes: diff --git a/backend/tests/test_videos.py b/backend/tests/test_videos.py index eb330963f..d51b424b8 100644 --- a/backend/tests/test_videos.py +++ b/backend/tests/test_videos.py @@ -31,9 +31,7 @@ ) from app.routes.videos import router as videos_router -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture(scope="function") @@ -116,9 +114,7 @@ def make_video_record(video_id, path, folder_id, **overrides): return record -# ############################## # Validation and scanning -# ############################## class TestVideoValidation: @@ -174,9 +170,7 @@ def test_recursive_vs_non_recursive(self, temp_media_dir): assert len(flat) == 1 -# ############################## # Thumbnails and metadata -# ############################## class TestVideoThumbnailAndMetadata: @@ -392,9 +386,7 @@ def test_rescan_regenerates_a_missing_thumbnail( assert os.path.exists(refreshed["thumbnailPath"]) -# ############################## # Database -# ############################## class TestVideosDatabase: @@ -476,9 +468,7 @@ def test_get_video_by_id(self, test_db, test_folder_id): assert db_get_video_by_id("missing") is None -# ############################## # Routes -# ############################## class TestVideosAPI: @@ -588,9 +578,7 @@ def boom(vid): assert detail["message"] -# ############################## # Utility edge cases -# ############################## class TestVideoUtilEdgeCases: diff --git a/backend/tests/test_yolo_mapping.py b/backend/tests/test_yolo_mapping.py index 7e10edaeb..3ac617ec5 100644 --- a/backend/tests/test_yolo_mapping.py +++ b/backend/tests/test_yolo_mapping.py @@ -8,9 +8,7 @@ from app.database.yolo_mapping import db_create_YOLO_classes_table -# ############################## # Pytest Fixtures -# ############################## @pytest.fixture(scope="function") @@ -43,9 +41,7 @@ def fetch_mappings(db_path: str) -> List[Tuple[int, str]]: return rows -# ############################## # Table creation -# ############################## class TestCreateYOLOClassesTable: diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index b74c7d65c..25de426a5 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -366,10 +366,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..545d729e0 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, using +//! the platform's own ssh rather than a bundled ~40MB binary. The child is owned +//! here so it dies with the app: an orphan leaves an album reachable publicly. 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,8 @@ 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. fn find_url(line: &str, suffix: &str) -> Option { line.split_whitespace() .filter(|token| token.starts_with("https://")) @@ -349,13 +322,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 +434,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/memories.ts b/frontend/src/api/api-functions/memories.ts index 079725c02..b88719969 100644 --- a/frontend/src/api/api-functions/memories.ts +++ b/frontend/src/api/api-functions/memories.ts @@ -63,10 +63,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; } 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/components/Albums/ShareAlbumDialog.tsx b/frontend/src/components/Albums/ShareAlbumDialog.tsx index 52d59395a..0d3ed3cc2 100644 --- a/frontend/src/components/Albums/ShareAlbumDialog.tsx +++ b/frontend/src/components/Albums/ShareAlbumDialog.tsx @@ -209,10 +209,8 @@ export const ShareAlbumDialog: React.FC = ({ setCreatedShare(null); setSelectedUrl(''); setCopied(false); - // A share made earlier is still served by whatever tunnel is up, so ask - // rather than assume it was a local one. The mode follows the answer: - // showing an internet link while the help button explains local sharing - // would describe the wrong thing. + // An earlier share is served by whatever tunnel is up, so ask rather than + // assume local -- the mode has to follow the answer, not the other way round. tunnel.refresh().then((current) => { if (current) { setMode('internet'); diff --git a/frontend/src/components/BackgroundTasks/BackgroundTaskAlert.tsx b/frontend/src/components/BackgroundTasks/BackgroundTaskAlert.tsx index 750ad8613..ba1f348f2 100644 --- a/frontend/src/components/BackgroundTasks/BackgroundTaskAlert.tsx +++ b/frontend/src/components/BackgroundTasks/BackgroundTaskAlert.tsx @@ -15,10 +15,7 @@ export interface BackgroundTaskAlertProps { className?: string; } -/** - * Generic floating alert for long-running background work. Purely - * presentational — pair it with a status hook that decides when and what. - */ +/** Purely presentational -- pair it with a status hook that decides when. */ export const BackgroundTaskAlert: React.FC = ({ title, description, diff --git a/frontend/src/components/BackgroundTasks/LibraryProcessingIndicator.tsx b/frontend/src/components/BackgroundTasks/LibraryProcessingIndicator.tsx index a8786cb7e..e4d2c8015 100644 --- a/frontend/src/components/BackgroundTasks/LibraryProcessingIndicator.tsx +++ b/frontend/src/components/BackgroundTasks/LibraryProcessingIndicator.tsx @@ -12,10 +12,7 @@ interface Dismissal { totalItems: number; } -/** - * App-wide alert showing the current background pass (tagging → indexing). - * Hidden on Settings, which has its own bars. Needs router context. - */ +// Hidden on Settings, which has its own bars. Needs router context. export const LibraryProcessingIndicator: React.FC = () => { const { phase, percentage, totalItems, semanticAvailable } = useLibraryProcessingStatus(); diff --git a/frontend/src/components/Media/MediaView.tsx b/frontend/src/components/Media/MediaView.tsx index b72b6dfde..b3faa8a73 100644 --- a/frontend/src/components/Media/MediaView.tsx +++ b/frontend/src/components/Media/MediaView.tsx @@ -150,7 +150,6 @@ export function MediaView({ resetViewerState(); }, [resetViewerState]); - // Keyboard navigation useKeyboardNavigation({ onClose: handleClose, onNext: handleNextImage, @@ -166,10 +165,8 @@ export function MediaView({ return null; } - // Safe variables const currentImagePath = currentImage.path; const currentImageKey = currentImage.id || currentImage.path; - // console.log(currentImage); const currentImageAlt = `image-${currentViewIndex}`; return (
diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx index a71295861..79a740b59 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -810,10 +810,8 @@ describe('ZoomableImage controlled transform behavior', () => { imageSize: { width: 200, height: 100 }, }); - // A line-mode wheel (deltaMode === 1) reports scroll in lines, not pixels. - // It must be normalized by LINE_HEIGHT_MULTIPLIER (33), so a 3-line notch - // produces the same zoomRatio as a 99px pixel-mode notch: exp(99 * 0.001). - // Without the multiplier, a 3-line notch would produce exp(3 * 0.001) instead. + // A line-mode wheel reports lines, not pixels, so LINE_HEIGHT_MULTIPLIER (33) + // must make a 3-line notch match a 99px one: exp(99 * 0.001), not exp(3 * 0.001). fireEvent.wheel(viewport, { deltaY: -3, deltaMode: 1, @@ -904,10 +902,8 @@ describe('ZoomableImage controlled transform behavior', () => { }); describe('control button zoom animation', () => { - // jsdom has no TransitionEvent constructor, and fireEvent.transitionEnd does - // not deliver `propertyName` to React's synthetic event. Build the event - // manually (mirroring firePointerEvent) so the handler's property filter - // sees a real value. + // jsdom has no TransitionEvent, and fireEvent.transitionEnd drops + // `propertyName`, so build it manually for the handler's property filter. const fireTransitionEnd = (element: Element, propertyName: string) => { const event = new Event('transitionend', { bubbles: true, diff --git a/frontend/src/components/Memories/ConvertMemoryToAlbumDialog.tsx b/frontend/src/components/Memories/ConvertMemoryToAlbumDialog.tsx index 2d091406e..84885962a 100644 --- a/frontend/src/components/Memories/ConvertMemoryToAlbumDialog.tsx +++ b/frontend/src/components/Memories/ConvertMemoryToAlbumDialog.tsx @@ -24,10 +24,7 @@ interface ConvertMemoryToAlbumDialogProps { onClose: () => void; } -/** - * Copies a memory's photos into a new album. The name is pre-filled from the - * memory's title; everything else about the album is edited afterwards. - */ +/** Copies a memory's photos into a new album; the name pre-fills from its title. */ export const ConvertMemoryToAlbumDialog: React.FC< ConvertMemoryToAlbumDialogProps > = ({ memory, isOpen, onClose }) => { diff --git a/frontend/src/components/Memories/MemoryCard.tsx b/frontend/src/components/Memories/MemoryCard.tsx index dd16b80bb..1a633fa28 100644 --- a/frontend/src/components/Memories/MemoryCard.tsx +++ b/frontend/src/components/Memories/MemoryCard.tsx @@ -27,11 +27,8 @@ interface MemoryCardProps { onConvertToAlbum: (memoryId: string) => void; } -/** - * Grid tile for one memory. The cover scales and the caption lifts on hover; - * the tile opens the story viewer, and the actions menu sits beside it rather - * than inside it - a button cannot contain another button. - */ +// The actions menu sits beside the tile rather than inside it: a button cannot +// contain another button. export const MemoryCard: React.FC = ({ memory, onOpen, diff --git a/frontend/src/components/Memories/MemoryFilmstrip.tsx b/frontend/src/components/Memories/MemoryFilmstrip.tsx index d70a64aa2..85fcf8442 100644 --- a/frontend/src/components/Memories/MemoryFilmstrip.tsx +++ b/frontend/src/components/Memories/MemoryFilmstrip.tsx @@ -13,10 +13,7 @@ interface MemoryFilmstripProps { onSelect: (memoryId: string) => void; } -/** - * Horizontal strip of circular covers for jumping between memories without - * leaving the story viewer. - */ +/** Jumps between memories without leaving the story viewer. */ export const MemoryFilmstrip: React.FC = ({ memories, activeMemoryId, diff --git a/frontend/src/components/Memories/MemoryStoryViewer.tsx b/frontend/src/components/Memories/MemoryStoryViewer.tsx index b8404ada1..e011f0be4 100644 --- a/frontend/src/components/Memories/MemoryStoryViewer.tsx +++ b/frontend/src/components/Memories/MemoryStoryViewer.tsx @@ -61,10 +61,7 @@ interface MemoryStoryViewerProps { musicEnabled: boolean; } -/** - * Full-screen, Instagram-style viewer: segmented progress bars, autoplay, - * keyboard and swipe navigation, and a filmstrip of other memories. - */ +/** Full-screen story viewer: progress bars, autoplay, keyboard and swipe nav. */ export const MemoryStoryViewer: React.FC = ({ memoryId, memories, diff --git a/frontend/src/components/Memories/__tests__/MemoryStoryViewer.test.tsx b/frontend/src/components/Memories/__tests__/MemoryStoryViewer.test.tsx index c17566fdd..17c0a23f7 100644 --- a/frontend/src/components/Memories/__tests__/MemoryStoryViewer.test.tsx +++ b/frontend/src/components/Memories/__tests__/MemoryStoryViewer.test.tsx @@ -72,10 +72,8 @@ const makeMockStory = (overrides: Partial = {}): MemoryStory => ...overrides, }) as MemoryStory; -/** - * The theme ships muted, so ducking is only observable with it turned on - - * otherwise these assertions pass on the default and prove nothing. - */ +// The theme ships muted, so ducking is only observable with it on -- otherwise +// these assertions pass on the default and prove nothing. const renderViewer = (themeAudible = false) => render(, { preloadedState: { diff --git a/frontend/src/components/VideoPlayer/NetflixStylePlayer.tsx b/frontend/src/components/VideoPlayer/NetflixStylePlayer.tsx index 2b836b3c2..d20de143d 100644 --- a/frontend/src/components/VideoPlayer/NetflixStylePlayer.tsx +++ b/frontend/src/components/VideoPlayer/NetflixStylePlayer.tsx @@ -59,12 +59,7 @@ export default function NetflixStylePlayer({ const containerRef = useRef(null); const hideControlsTimeoutRef = useRef(undefined); - // const resolvedSrc = useMemo(() => convertFileSrc(videoSrc), [videoSrc]); - const resolvedSrc = useMemo(() => { - const src = convertFileSrc(videoSrc); - console.log('Resolved video src:', src); - return src; - }, [videoSrc]); + const resolvedSrc = useMemo(() => convertFileSrc(videoSrc), [videoSrc]); // Reset per-source state. The overlay remounts the player per video, but this // keeps the component correct if a caller swaps videoSrc without remounting. diff --git a/frontend/src/components/__tests__/Navbar.test.tsx b/frontend/src/components/__tests__/Navbar.test.tsx index 49bbdf5eb..74b519773 100644 --- a/frontend/src/components/__tests__/Navbar.test.tsx +++ b/frontend/src/components/__tests__/Navbar.test.tsx @@ -178,10 +178,8 @@ describe('Navbar Component', () => { fireEvent.click(screen.getByPlaceholderText('Add to your search')); expect(screen.getByText('Favourites')).toBeInTheDocument(); - // Click Favourites fireEvent.click(screen.getByText('Favourites')); - // Should navigate to /favourites expect(mockNavigate).toHaveBeenCalledWith('/favourites'); // Dropdown should be closed @@ -217,7 +215,6 @@ describe('Navbar Component', () => { expect(screen.queryByText('Favourites')).not.toBeInTheDocument(); const searchInput = screen.getByPlaceholderText('Add to your search'); - // Focus the input directly using fireEvent fireEvent.focus(searchInput); expect(screen.getByText('Favourites')).toBeInTheDocument(); }); diff --git a/frontend/src/components/__tests__/Sidebar.test.tsx b/frontend/src/components/__tests__/Sidebar.test.tsx index 327e036bb..2e80679ff 100644 --- a/frontend/src/components/__tests__/Sidebar.test.tsx +++ b/frontend/src/components/__tests__/Sidebar.test.tsx @@ -89,7 +89,6 @@ describe('Sidebar', () => { `/${startRoute}`, ); - // click nav link await user.click(screen.getByText(linkText)); // verify navigation diff --git a/frontend/src/constants/layout.ts b/frontend/src/constants/layout.ts index 57948f11a..1a8aebdb7 100644 --- a/frontend/src/constants/layout.ts +++ b/frontend/src/constants/layout.ts @@ -1,9 +1,4 @@ -/** - * The card grid used by every media surface. - * - * Intrinsic rather than breakpoint-driven: the cards keep a near-constant - * width and the column count follows the window, so resizing reflows the - * grid instead of resizing every card. Callers add their own padding. - */ +// Intrinsic rather than breakpoint-driven: 224px decides the column count and +// the 1fr shares out the remainder, so no resize leaves a ragged trailing gap. export const MEDIA_GRID_CLASS = 'grid grid-cols-[repeat(auto-fill,_minmax(224px,_1fr))] gap-4'; diff --git a/frontend/src/features/faceClustersSlice.ts b/frontend/src/features/faceClustersSlice.ts index 12eabb82f..b6e7a7300 100644 --- a/frontend/src/features/faceClustersSlice.ts +++ b/frontend/src/features/faceClustersSlice.ts @@ -5,12 +5,10 @@ export interface FaceClustersState { clusters: Cluster[]; } -// Initial state const initialState: FaceClustersState = { clusters: [], }; -// Face clusters slice const faceClustersSlice = createSlice({ name: 'faceClusters', initialState, diff --git a/frontend/src/features/folderSelectors.ts b/frontend/src/features/folderSelectors.ts index 77693f767..000e83319 100644 --- a/frontend/src/features/folderSelectors.ts +++ b/frontend/src/features/folderSelectors.ts @@ -20,18 +20,15 @@ export const selectFoldersByParentId = createSelector( folders.filter((folder) => folder.parent_folder_id === parentId), ); -// Get root folders (folders with no parent) export const selectRootFolders = createSelector([selectAllFolders], (folders) => folders.filter((folder) => !folder.parent_folder_id), ); -// Get folders with AI tagging enabled export const selectAITaggingEnabledFolders = createSelector( [selectAllFolders], (folders) => folders.filter((folder) => folder.AI_Tagging), ); -// Get folders with tagging completed export const selectTaggingCompletedFolders = createSelector( [selectAllFolders], (folders) => folders.filter((folder) => folder.taggingCompleted), @@ -46,7 +43,6 @@ export const selectFoldersByPathPattern = createSelector( ), ); -// Folder hierarchy selectors export const selectFolderHierarchy = createSelector( [selectAllFolders], (folders) => { @@ -85,7 +81,6 @@ export const selectRecentFolders = createSelector( .slice(0, limit), ); -// Folders grouped by parent export const selectFoldersGroupedByParent = createSelector( [selectAllFolders], (folders) => { diff --git a/frontend/src/features/folderSlice.ts b/frontend/src/features/folderSlice.ts index 2efbe42f6..80841137d 100644 --- a/frontend/src/features/folderSlice.ts +++ b/frontend/src/features/folderSlice.ts @@ -17,12 +17,10 @@ const folderSlice = createSlice({ name: 'folders', initialState, reducers: { - // Set all folders setFolders(state, action: PayloadAction) { state.folders = action.payload; }, - // Add a single folder addFolder(state, action: PayloadAction) { const newFolder = action.payload; const existingIndex = state.folders.findIndex( @@ -32,12 +30,10 @@ const folderSlice = createSlice({ if (existingIndex === -1) { state.folders.push(newFolder); } else { - // Update existing folder state.folders[existingIndex] = newFolder; } }, - // Update an existing folder updateFolder( state, action: PayloadAction<{ @@ -58,7 +54,6 @@ const folderSlice = createSlice({ } }, - // Remove folders by IDs removeFolders(state, action: PayloadAction) { const folderIdsToRemove = action.payload; state.folders = state.folders.filter( @@ -71,7 +66,6 @@ const folderSlice = createSlice({ state.folders = []; }, - // Set tagging status for folders setTaggingStatus(state, action: PayloadAction) { const map: Record = {}; for (const info of action.payload) { @@ -81,7 +75,6 @@ const folderSlice = createSlice({ state.lastUpdatedAt = Date.now(); }, - // Clear tagging status clearTaggingStatus(state) { state.taggingStatus = {}; state.lastUpdatedAt = undefined; diff --git a/frontend/src/features/memoriesSlice.ts b/frontend/src/features/memoriesSlice.ts index aab74f282..7c2c7080a 100644 --- a/frontend/src/features/memoriesSlice.ts +++ b/frontend/src/features/memoriesSlice.ts @@ -1,9 +1,5 @@ -/** - * Memories Redux slice. - * - * Holds story-viewer UI state only. Memory data itself lives in React Query, - * matching how the rest of the app splits server and UI state. - */ +// Story-viewer UI state only. Memory data lives in React Query, matching how the +// rest of the app splits server and UI state. import { createSlice, PayloadAction } from '@reduxjs/toolkit'; diff --git a/frontend/src/hooks/__tests__/useFolderOperations.test.tsx b/frontend/src/hooks/__tests__/useFolderOperations.test.tsx index 80c101b23..cefeb639e 100644 --- a/frontend/src/hooks/__tests__/useFolderOperations.test.tsx +++ b/frontend/src/hooks/__tests__/useFolderOperations.test.tsx @@ -72,10 +72,8 @@ describe('useFolderOperations - delete folder cache invalidation', () => { result.current.deleteFolder('folder-1'); - // autoInvalidateTags still fires ['folders'] on settle regardless of - // outcome, so wait for that instead of an arbitrary timeout to know the - // mutation has actually settled before asserting clusters was skipped. - // retry: 2 with a 500ms retryDelay means settling can take >1s. + // autoInvalidateTags fires ['folders'] on settle either way, so wait for that + // rather than a timeout -- retry: 2 at 500ms means settling can exceed 1s. await waitFor( () => { expect( diff --git a/frontend/src/hooks/__tests__/useUserPreferences.test.tsx b/frontend/src/hooks/__tests__/useUserPreferences.test.tsx index 43832b18c..7b5b4e1e8 100644 --- a/frontend/src/hooks/__tests__/useUserPreferences.test.tsx +++ b/frontend/src/hooks/__tests__/useUserPreferences.test.tsx @@ -72,13 +72,8 @@ const mountLoaded = async () => { const sentBodies = () => mockUpdateUserPreferences.mock.calls.map(([body]) => body); -/** - * Let the query effect run. - * - * A response reaches the cache a tick before the effect that would apply it, - * so asserting straight after the request resolves reads state that has not - * been overwritten yet, whether or not anything guards it. - */ +// A response reaches the cache a tick before the effect that applies it, so +// asserting on resolve reads state nothing has overwritten yet. const settle = async () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/frontend/src/hooks/useFolderOperations.tsx b/frontend/src/hooks/useFolderOperations.tsx index cdd0f2c6a..54eb86115 100644 --- a/frontend/src/hooks/useFolderOperations.tsx +++ b/frontend/src/hooks/useFolderOperations.tsx @@ -14,16 +14,11 @@ import { FolderDetails, isIndexingPending } from '@/types/Folder'; import { useMutationFeedback } from './useMutationFeedback'; import { getFoldersTaggingStatus } from '@/api/api-functions/folders'; -/** - * Custom hook for folder operations - * Manages folder queries, AI tagging mutations, and folder deletion - */ export const useFolderOperations = () => { const dispatch = useDispatch(); const queryClient = useQueryClient(); const folders = useSelector(selectAllFolders); - // Query for folders const foldersQuery = usePictoQuery({ queryKey: ['folders'], queryFn: getAllFolders, @@ -47,7 +42,6 @@ export const useFolderOperations = () => { refetchOnWindowFocus: false, // Don't refetch when window gains focus }); - // Apply feedback to the folders query useMutationFeedback( { isPending: foldersQuery.isLoading, @@ -64,7 +58,6 @@ export const useFolderOperations = () => { }, ); - // Update Redux store when folders data changes useEffect(() => { if (foldersQuery.data?.data?.folders) { const folders = foldersQuery.data.data.folders as FolderDetails[]; @@ -72,7 +65,6 @@ export const useFolderOperations = () => { } }, [foldersQuery.data, dispatch]); - // Update Redux store with tagging status on each poll useEffect(() => { if (taggingStatusQuery.data?.success) { const raw = taggingStatusQuery.data.data as any; @@ -98,14 +90,12 @@ export const useFolderOperations = () => { taggingStatusQuery.errorMessage, ]); - // Enable AI tagging mutation const enableAITaggingMutation = usePictoMutation({ mutationFn: async (folder_id: string) => enableAITagging({ folder_ids: [folder_id] }), autoInvalidateTags: ['folders'], }); - // Apply feedback to the enable AI tagging mutation useMutationFeedback(enableAITaggingMutation, { showLoading: true, loadingMessage: 'Enabling AI tagging', @@ -114,14 +104,12 @@ export const useFolderOperations = () => { errorMessage: 'Failed to enable AI tagging. Please try again.', }); - // Disable AI tagging mutation const disableAITaggingMutation = usePictoMutation({ mutationFn: async (folder_id: string) => disableAITagging({ folder_ids: [folder_id] }), autoInvalidateTags: ['folders'], }); - // Apply feedback to the disable AI tagging mutation useMutationFeedback(disableAITaggingMutation, { showLoading: true, loadingMessage: 'Disabling AI tagging', @@ -131,21 +119,17 @@ export const useFolderOperations = () => { errorMessage: 'Failed to disable AI tagging. Please try again.', }); - // Delete folder mutation const deleteFolderMutation = usePictoMutation({ mutationFn: async (folder_id: string) => deleteFolders({ folder_ids: [folder_id] }), autoInvalidateTags: ['folders'], - // Deleting a folder cascades to its images and faces, so any cluster built from - // them is now stale. This has to be a separate call: autoInvalidateTags is passed - // through as a single queryKey and matches by prefix, so ['folders', 'clusters'] - // would match neither query. + // Deleting a folder cascades to its images and faces, so clusters go stale. + // Separate call: autoInvalidateTags is one prefix-matched key, not a list. onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['clusters'] }); }, }); - // Apply feedback to the delete folder mutation useMutationFeedback(deleteFolderMutation, { showLoading: true, loadingMessage: 'Deleting folder', @@ -156,9 +140,6 @@ export const useFolderOperations = () => { errorMessage: 'Failed to delete the folder. Please try again.', }); - /** - * Toggle AI tagging for a folder - */ const toggleAITagging = (folder: FolderDetails) => { if (folder.AI_Tagging) { disableAITaggingMutation.mutate(folder.folder_id); @@ -167,9 +148,6 @@ export const useFolderOperations = () => { } }; - /** - * Delete a folder - */ const deleteFolder = (folderId: string) => { deleteFolderMutation.mutate(folderId); }; diff --git a/frontend/src/hooks/useLibraryProcessingStatus.ts b/frontend/src/hooks/useLibraryProcessingStatus.ts index 2ac8ce8b2..469f33f1d 100644 --- a/frontend/src/hooks/useLibraryProcessingStatus.ts +++ b/frontend/src/hooks/useLibraryProcessingStatus.ts @@ -40,11 +40,8 @@ const aggregate = (data: APIResponse | undefined) => { return { totalItems, taggedItems, embeddedItems }; }; -/** - * Aggregated background-processing progress across all AI-tagging folders. - * Derived purely from polled database state (not task events), so it - * survives restarts and refreshes. Polls fast while busy, slow when idle. - */ +// Derived from polled database state, not task events, so it survives restarts +// and refreshes. Polls fast while busy, slow when idle. export const useLibraryProcessingStatus = (): LibraryProcessingStatus => { const { data: statusData, isSuccess: isStatusSuccess } = usePictoQuery({ queryKey: ['models', 'status'], diff --git a/frontend/src/hooks/useMemories.tsx b/frontend/src/hooks/useMemories.tsx index 23aa30b7a..3c7831821 100644 --- a/frontend/src/hooks/useMemories.tsx +++ b/frontend/src/hooks/useMemories.tsx @@ -25,13 +25,8 @@ export const MEMORIES_QUERY_KEY = ['memories']; /** How often the status endpoint is polled while a run is in flight. */ const RUN_POLL_INTERVAL_MS = 2000; -/** - * How long to keep watching for a queued run to start. - * - * Curation shares one single-worker executor with indexing and semantic - * scoring, so a run can sit queued for a while - but not indefinitely, and - * watching forever would poll forever. - */ +// Curation shares one single-worker executor, so a run can sit queued a while -- +// but watching forever would poll forever. const RUN_START_TIMEOUT_MS = 90_000; // usePictoQuery cannot infer its payload generic from the query function, so @@ -68,14 +63,8 @@ export const useMemory = (memoryId?: string) => { }); }; -/** - * Scheduler snapshot. Polled while a run is in progress. - * - * `forcePolling` covers the gap before a run is visible: POST /generate - * returns once the run is queued, which is earlier than the worker process - * writing 'running', so waiting for that status to appear on its own can - * mean never polling at all. - */ +// `forcePolling` covers the gap after /generate: refetchInterval reads the +// cached status, still 'complete', so polling would never start on its own. export const useMemoryStatus = ( enabled: boolean = true, forcePolling: boolean = false, @@ -95,16 +84,8 @@ export const useMemoryStatus = ( ); }; -/** - * Request a curation run and keep the grid in step with it. - * - * POST /generate returns as soon as the run is *queued*; the run itself - * happens in another process seconds or minutes later. Invalidating on the - * response therefore refetches the same memories that were already on - * screen, which is why Refresh appeared to do nothing until the page was - * reloaded by hand. This follows the run instead, refreshing the grid as - * results land and once more when it settles. - */ +// /generate returns at queue time, so invalidating on the response refetches +// what is already on screen -- why Refresh looked dead. This follows the run. export const useRefreshMemories = () => { const queryClient = useQueryClient(); const [isAwaitingRun, setIsAwaitingRun] = useState(false); @@ -142,10 +123,8 @@ export const useRefreshMemories = () => { wasRunActive.current = isRunActive; }, [isRunActive, statusUpdatedAt, queryClient]); - // Stop force-polling once our run is on record. Keyed on the start time - // rather than on seeing 'running': a run over a small library can begin - // and finish between two polls, and waiting to catch it mid-flight left - // the button spinning long after the memories had landed. + // Keyed on start time, not on catching 'running': a small library can begin + // and finish between polls, leaving the button spinning after results landed. useEffect(() => { if (!isAwaitingRun) return; diff --git a/frontend/src/hooks/useMutationFeedback.tsx b/frontend/src/hooks/useMutationFeedback.tsx index 7f370af51..1136eee7a 100644 --- a/frontend/src/hooks/useMutationFeedback.tsx +++ b/frontend/src/hooks/useMutationFeedback.tsx @@ -12,52 +12,18 @@ type MutationState = { }; type FeedbackOptions = { - /** - * Show loading state - */ showLoading?: boolean; - /** - * Custom loading message - */ loadingMessage?: string; - /** - * Show success message - */ showSuccess?: boolean; - /** - * Custom success title - */ successTitle?: string; - /** - * Custom success message - */ successMessage?: string; - /** - * Show error message - */ showError?: boolean; - /** - * Custom error title - */ errorTitle?: string; - /** - * Custom error message - */ errorMessage?: string; - /** - * Optional callback on success - */ onSuccess?: () => void; - /** - * Optional callback on error - */ onError?: (error: Error | unknown) => void; }; -/** - * Custom hook to provide standardized feedback for mutation states - * Handles loading indicators, success messages, and error messages - */ export const useMutationFeedback = ( mutationState: MutationState, options: FeedbackOptions = {}, @@ -77,6 +43,8 @@ export const useMutationFeedback = ( onError, } = options; + // Held in refs so an inline callback, which is a new function every render, + // stays out of the effect deps below and cannot re-fire the dialog. const onSuccessRef = useRef(onSuccess); const onErrorRef = useRef(onError); onSuccessRef.current = onSuccess; @@ -84,7 +52,6 @@ export const useMutationFeedback = ( const { isPending, isSuccess, isError, error } = mutationState; - // Handle loading state useEffect(() => { if (showLoading && isPending) { dispatch(showLoader(loadingMessage)); @@ -93,7 +60,6 @@ export const useMutationFeedback = ( } }, [isPending, showLoading, loadingMessage, dispatch]); - // Handle success state useEffect(() => { if (isSuccess && showSuccess) { dispatch( @@ -110,7 +76,6 @@ export const useMutationFeedback = ( } }, [isSuccess, showSuccess, successTitle, successMessage, dispatch]); - // Handle error state useEffect(() => { if (isError && showError) { const errorMsg = getErrorMessage(error, errorMessage); @@ -129,7 +94,6 @@ export const useMutationFeedback = ( } }, [isError, showError, errorTitle, errorMessage, error, dispatch]); - // Return original state for convenience return mutationState; }; diff --git a/frontend/src/hooks/usePersistedSort.ts b/frontend/src/hooks/usePersistedSort.ts index 768035470..f76c99a4d 100644 --- a/frontend/src/hooks/usePersistedSort.ts +++ b/frontend/src/hooks/usePersistedSort.ts @@ -1,12 +1,7 @@ import { useCallback, useState } from 'react'; -/** - * A sort selection that survives a reload, stored per surface like the theme. - * - * The stored value is checked against the options currently on offer: one left - * behind by an older build would otherwise sort the grid by nothing, with no - * option showing as selected and no way to tell why. - */ +// The stored value is checked against the options on offer: one left by an older +// build would sort the grid by nothing, with no option showing as selected. export function usePersistedSort( storageKey: string, defaultValue: T, diff --git a/frontend/src/hooks/useShareTunnel.ts b/frontend/src/hooks/useShareTunnel.ts index eef0641be..a79c3cfe3 100644 --- a/frontend/src/hooks/useShareTunnel.ts +++ b/frontend/src/hooks/useShareTunnel.ts @@ -2,14 +2,8 @@ import { useCallback, useRef, useState } from 'react'; import { startTunnel, stopTunnel, tunnelStatus } from '@/utils/tunnel'; import { ShareTunnel } from '@/types/Share'; -/** - * The tunnel that makes the share server reachable off the LAN. - * - * One tunnel forwards the whole share port, so it belongs to the application - * rather than to any one album. This owns the view of it, and deliberately - * asks the Rust side rather than trusting what a dialog last saw: a share can - * be stopped before the first status lookup has even resolved. - */ +// One tunnel forwards the whole share port, so it belongs to the app, not to an +// album. Asks the Rust side rather than trusting what a dialog last saw. export const useShareTunnel = (): ShareTunnel => { const [url, setUrl] = useState(null); const [isConnecting, setIsConnecting] = useState(false); @@ -60,10 +54,8 @@ export const useShareTunnel = (): ShareTunnel => { const close = useCallback(async (): Promise => { revision.current += 1; - // Asked for unconditionally rather than only when something is known to be - // running: a start still in flight has a child the owner can already kill, - // and asking first would read null and skip the stop entirely. Stopping - // when nothing is open is a no-op. + // Unconditional: checking first would read null while a start is in flight + // and skip the stop, which queues behind that start anyway. Idle is a no-op. await stopTunnel(); apply(null); }, [apply]); diff --git a/frontend/src/hooks/useStoryProgress.ts b/frontend/src/hooks/useStoryProgress.ts index 4428d037d..62255ec4e 100644 --- a/frontend/src/hooks/useStoryProgress.ts +++ b/frontend/src/hooks/useStoryProgress.ts @@ -8,13 +8,8 @@ interface UseStoryProgressOptions { onComplete: () => void; } -/** - * Drives the segmented progress bar in the story viewer. - * - * Uses requestAnimationFrame rather than setInterval so the bar fills smoothly - * and pauses resume from where they stopped instead of restarting the slide. - * Returns progress through the current slide, 0 to 1. - */ +// requestAnimationFrame, not setInterval, so the bar fills smoothly and a pause +// resumes where it stopped. Returns progress through the slide, 0 to 1. export const useStoryProgress = ({ index, durationMs, diff --git a/frontend/src/hooks/useUserPreferences.tsx b/frontend/src/hooks/useUserPreferences.tsx index 8a6aedad5..ca9584bdc 100644 --- a/frontend/src/hooks/useUserPreferences.tsx +++ b/frontend/src/hooks/useUserPreferences.tsx @@ -27,10 +27,6 @@ export const DEFAULT_MEMORIES_PREFERENCES: MemoriesPreferences = { }, }; -/** - * Custom hook for user preferences - * Manages preferences state and mutation operations - */ export const useUserPreferences = () => { const [preferences, setPreferences] = useState({ YOLO_model_size: 'nano', @@ -50,14 +46,11 @@ export const useUserPreferences = () => { // Non-zero from the moment a write is queued until it settles. const pendingWrites = useRef(0); - // Bumped when a write is queued. Any read already in flight at that point - // describes the server from before it, however late the response arrives, - // which is why this counts reads rather than timing them: a read that starts - // first can still finish last. + // Bumped when a write is queued. A read stamps the value it started under, so + // one overtaken by a write is discarded however late its response arrives. const writeEpoch = useRef(0); const readEpoch = useRef(0); - // Query for user preferences const preferencesQuery = usePictoQuery({ queryKey: ['userPreferences'], queryFn: () => { @@ -66,7 +59,6 @@ export const useUserPreferences = () => { }, }); - // Update local state when preferences data changes useEffect(() => { // Applying stale server state would revert the write and hand the next // queued one a stale base to build on. @@ -88,7 +80,6 @@ export const useUserPreferences = () => { mutationFn: updateUserPreferences, }); - // Apply feedback to the update preferences mutation but hide loader and success dialog useMutationFeedback(updatePreferencesMutation, { showLoading: false, // Don't show the loading indicator to prevent flicker loadingMessage: 'Updating preferences', @@ -104,15 +95,9 @@ export const useUserPreferences = () => { // value rather than what is actually stored. const writeQueue = useRef>(Promise.resolve()); - /** - * Apply a change optimistically and send it, queued behind any write already - * running. - * - * `build` runs when the write reaches the front of the queue, not when it was - * requested, so a queued change is computed from what actually landed before - * it. It returns the full next state and the request body, which carries only - * the changed keys so a concurrent edit elsewhere in settings survives. - */ + // `build` runs when the write reaches the front of the queue, not when it was + // requested, so it sees whatever actually landed first. Its request carries + // only changed keys, leaving a concurrent edit elsewhere in settings intact. const writePreferences = ( build: (current: UserPreferencesData) => { next: UserPreferencesData; @@ -150,18 +135,12 @@ export const useUserPreferences = () => { return result; }; - /** - * Update YOLO model size - */ const updateYoloModelSize = async (size: 'nano' | 'small' | 'medium') => writePreferences((current) => ({ next: { ...current, YOLO_model_size: size }, request: { YOLO_model_size: size }, })); - /** - * Toggle GPU acceleration - */ const toggleGpuAcceleration = async () => writePreferences((current) => { const GPU_Acceleration = !current.GPU_Acceleration; @@ -171,18 +150,12 @@ export const useUserPreferences = () => { }; }); - /** - * Update the video keyframe sampling interval (seconds) - */ const updateVideoFrameInterval = async (interval: number) => writePreferences((current) => ({ next: { ...current, Video_Frame_Interval: interval }, request: { Video_Frame_Interval: interval }, })); - /** - * Patch memories preferences. - */ const updateMemoriesPreferences = async ( patch: UpdateUserPreferencesRequest['memories'], ) => diff --git a/frontend/src/layout/layout.tsx b/frontend/src/layout/layout.tsx index b3eaaf78b..c9f34690a 100644 --- a/frontend/src/layout/layout.tsx +++ b/frontend/src/layout/layout.tsx @@ -20,8 +20,9 @@ const Layout: React.FC = () => {
- {/* Contain scrolling here so Navbar's parent height never exceeds 100vh — now the navbar is stuck it will not go away. - hide-scrollbar keeps overflow-y-auto (needed for the sticky navbar) from painting a second, default scrollbar on Windows/WebView2. */} + {/* Scrolling is contained here so the Navbar's parent never exceeds + 100vh and the navbar stays put. hide-scrollbar stops WebView2 from + painting a second scrollbar beside the one overflow-y-auto adds. */}
diff --git a/frontend/src/lib/__tests__/utils.test.ts b/frontend/src/lib/__tests__/utils.test.ts index ea8b5922e..9a3361770 100644 --- a/frontend/src/lib/__tests__/utils.test.ts +++ b/frontend/src/lib/__tests__/utils.test.ts @@ -14,9 +14,7 @@ function fakeAxiosError( }; } -/* ------------------------------------------------------------------ */ -/* cn (classname merge) */ -/* ------------------------------------------------------------------ */ +// cn (classname merge) describe('cn', () => { test('merges multiple class names', () => { @@ -37,9 +35,7 @@ describe('cn', () => { }); }); -/* ------------------------------------------------------------------ */ -/* getErrorMessage */ -/* ------------------------------------------------------------------ */ +// getErrorMessage describe('getErrorMessage', () => { test('returns default message for null / undefined', () => { diff --git a/frontend/src/pages/Album/Album.tsx b/frontend/src/pages/Album/Album.tsx index c882bbe54..d3fb67001 100644 --- a/frontend/src/pages/Album/Album.tsx +++ b/frontend/src/pages/Album/Album.tsx @@ -51,11 +51,8 @@ const ALBUM_SORT_STORAGE_KEY = 'pictopy-albums-sort'; // Derived from the options above so a removed sort stops being restorable. const ALBUM_SORT_VALUES = ALBUM_SORT_OPTIONS.map((option) => option.value); -/** - * Newest first. SQLite timestamps are zero-padded, so they compare correctly - * as strings. Albums predating these columns have no timestamp: they read as - * oldest and keep the insertion order the backend lists them in. - */ +// SQLite timestamps are zero-padded, so string comparison sorts them correctly. +// Albums predating the column have none: they read as oldest, keeping insert order. const newestFirst = (a: string | null, b: string | null): number => (b ?? '').localeCompare(a ?? ''); diff --git a/frontend/src/pages/Album/AlbumDetail.tsx b/frontend/src/pages/Album/AlbumDetail.tsx index c65e1e856..12977b4a8 100644 --- a/frontend/src/pages/Album/AlbumDetail.tsx +++ b/frontend/src/pages/Album/AlbumDetail.tsx @@ -152,10 +152,8 @@ export const AlbumDetail = () => { const responseData = imagesData as any; const imageIds = (responseData?.image_ids || []) as string[]; - // Get full image data from all images const allImages = (allImagesData?.data || []) as Image[]; - // Filter images that are in this album const albumImages = allImages.filter((img) => imageIds.includes(img.id)); dispatch(setAlbumImages(albumImages)); diff --git a/frontend/src/pages/ModelManager/InstalledTab.tsx b/frontend/src/pages/ModelManager/InstalledTab.tsx index 44ba69291..9b89dfe1d 100644 --- a/frontend/src/pages/ModelManager/InstalledTab.tsx +++ b/frontend/src/pages/ModelManager/InstalledTab.tsx @@ -124,7 +124,6 @@ export const InstalledTab: React.FC = ({ const models = statusData.data; - // Group standard tiers const standardTiers = TIER_ORDER.map((tier) => { const objectModelKey = Object.keys(models).find( (key) => @@ -163,7 +162,6 @@ export const InstalledTab: React.FC = ({ .filter(([_, model]) => model.tier === 'required' && model.installed) .map(([key, model]) => ({ key, ...model })); - // Group semantic models const semanticModels = SEMANTIC_BUNDLE_KEYS.map((k) => models[k]).filter( Boolean, ); diff --git a/frontend/src/pages/SearchResults/SearchResults.tsx b/frontend/src/pages/SearchResults/SearchResults.tsx index a4387dff0..85737f341 100644 --- a/frontend/src/pages/SearchResults/SearchResults.tsx +++ b/frontend/src/pages/SearchResults/SearchResults.tsx @@ -79,12 +79,9 @@ export const SearchResults = () => { null, ); - // react-query only aborts a superseded query's signal from inside a - // useEffect (post-commit), so there's a brief window right after a new - // search starts where an older, still-running queryFn can reach its - // manual dispatch below with signal.aborted still false. This ref is - // updated synchronously during render -- no such window -- so it always - // reflects the truly current search, even before that effect runs. + // react-query aborts a superseded query post-commit, so just after a new + // search starts an older queryFn can still reach its dispatch below with + // signal.aborted false. This ref updates during render, leaving no such gap. const searchKey = `${query}::${mode}`; const searchKeyRef = useRef(null); const searchGenerationRef = useRef(0); @@ -561,7 +558,6 @@ export const SearchResults = () => { )} - {/* Media Viewer Modals */} {isImageViewOpen && } {isVideoViewOpen && }
diff --git a/frontend/src/pages/SettingsPage/Settings.tsx b/frontend/src/pages/SettingsPage/Settings.tsx index 188f23188..8472f296a 100644 --- a/frontend/src/pages/SettingsPage/Settings.tsx +++ b/frontend/src/pages/SettingsPage/Settings.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router'; -// Import modular components import FolderManagementCard from './components/FolderManagementCard'; import UserPreferencesCard from './components/UserPreferencesCard'; import ApplicationControlsCard from './components/ApplicationControlsCard'; @@ -10,10 +9,6 @@ import AccountSettingsCard, { } from './components/AccountSettingsCard'; import SystemSettingsCard from './components/SystemSettingsCard'; -/** - * Settings page component - * Acts as an orchestrator for the settings sections - */ const Settings: React.FC = () => { const location = useLocation(); const navigate = useNavigate(); diff --git a/frontend/src/pages/SettingsPage/components/ApplicationControlsCard.tsx b/frontend/src/pages/SettingsPage/components/ApplicationControlsCard.tsx index dc81ab7d7..c59bb6ac8 100644 --- a/frontend/src/pages/SettingsPage/components/ApplicationControlsCard.tsx +++ b/frontend/src/pages/SettingsPage/components/ApplicationControlsCard.tsx @@ -14,9 +14,6 @@ import { usePictoMutation } from '@/hooks/useQueryExtension'; import { useMutationFeedback } from '@/hooks/useMutationFeedback'; import { showGlobalAlert } from '@/features/globalAlertSlice'; -/** - * Component for application controls in settings - */ const ApplicationControlsCard: React.FC = () => { const dispatch = useDispatch(); diff --git a/frontend/src/pages/SettingsPage/components/SettingsCard.tsx b/frontend/src/pages/SettingsPage/components/SettingsCard.tsx index 183be138a..10c9049b1 100644 --- a/frontend/src/pages/SettingsPage/components/SettingsCard.tsx +++ b/frontend/src/pages/SettingsPage/components/SettingsCard.tsx @@ -2,27 +2,12 @@ import React from 'react'; import { LucideIcon } from 'lucide-react'; interface SettingsCardProps { - /** - * Icon to display in the card header - */ icon: LucideIcon; - /** - * Card title - */ title: string; - /** - * Card description - */ description?: string; - /** - * Card content - */ children: React.ReactNode; } -/** - * Reusable settings card component with consistent styling - */ const SettingsCard: React.FC = ({ icon: Icon, title, diff --git a/frontend/src/pages/SettingsPage/components/UserPreferencesCard.tsx b/frontend/src/pages/SettingsPage/components/UserPreferencesCard.tsx index d7f3a7dd5..4d686e56d 100644 --- a/frontend/src/pages/SettingsPage/components/UserPreferencesCard.tsx +++ b/frontend/src/pages/SettingsPage/components/UserPreferencesCard.tsx @@ -38,9 +38,6 @@ import { type ModelTier, } from '@/types/models'; -/** - * Component for managing user preferences in settings - */ // Coarse enough to be a meaningful cost tradeoff, fine enough to matter. const FRAME_INTERVAL_OPTIONS = [2, 5, 10, 30]; // Story pacing: below 3s a photo barely registers, above 10s it drags. diff --git a/frontend/src/pages/__tests__/SettingsPage.test.tsx b/frontend/src/pages/__tests__/SettingsPage.test.tsx index 184dab1c7..1b109663b 100644 --- a/frontend/src/pages/__tests__/SettingsPage.test.tsx +++ b/frontend/src/pages/__tests__/SettingsPage.test.tsx @@ -153,13 +153,4 @@ describe('Settings Page', () => { }); }); }); - - // eslint-disable-next-line no-warning-comments - /** - * FUTURE: System integrations (future scope) - * Belongs in E2E tests (Playwright/Cypress) rather than Jest - * - Full user flows with mocked/real backend - * - Update preferences API verification - * - Check for updates flow - */ }); diff --git a/frontend/src/store/hooks.ts b/frontend/src/store/hooks.ts index 96fc8a456..8cdda12a2 100644 --- a/frontend/src/store/hooks.ts +++ b/frontend/src/store/hooks.ts @@ -1,10 +1,3 @@ -/** - * Redux Hooks - * - * Typed hooks for use throughout the application. - * These hooks ensure type safety when using Redux with TypeScript. - */ - import { useDispatch, useSelector } from 'react-redux'; import type { TypedUseSelectorHook } from 'react-redux'; import type { RootState, AppDispatch } from '../app/store'; diff --git a/frontend/src/types/Folder.ts b/frontend/src/types/Folder.ts index 6e94917e5..e61579de6 100644 --- a/frontend/src/types/Folder.ts +++ b/frontend/src/types/Folder.ts @@ -16,14 +16,8 @@ export type IndexingStatus = | 'completed' | 'interrupted'; -/** - * Whether a walk is queued or running. - * - * 'interrupted' is a stopped state - a previous session died mid-walk - so it - * must not read as work in flight, or the card spins on a walk nobody is - * going to run. An unknown value is treated as pending, matching the old - * "anything but completed" behaviour. - */ +// 'interrupted' is stopped, not in flight, or the card spins on a walk nobody +// will run. Unknown reads as pending, matching the old "anything but completed". export const isIndexingPending = (status?: IndexingStatus): boolean => status !== 'completed' && status !== 'interrupted'; diff --git a/frontend/src/types/Share.ts b/frontend/src/types/Share.ts index 11fa6f372..b9100af8e 100644 --- a/frontend/src/types/Share.ts +++ b/frontend/src/types/Share.ts @@ -20,10 +20,8 @@ export interface Share { urls: ShareUrl[]; } -/** - * Where a share can be reached from. LAN keeps everything on the local network; - * internet opens a tunnel, which means the photos pass through a third party. - */ +// LAN stays on the local network; internet opens a tunnel, so the photos pass +// through a third party. export type ShareMode = 'lan' | 'internet'; export interface CreateShareRequest { @@ -47,10 +45,7 @@ export interface ShareTunnel { export interface ShareAlbumDialogProps { album: Album | null; - /** - * Every live share for this album, newest first. An album can be shared more - * than once, and stopping has to account for all of them. - */ + /** Newest first. An album can hold several, and stopping must cover them all. */ shares: Share[]; isOpen: boolean; onClose: () => void; diff --git a/frontend/src/utils/PFPutils/cropImage.ts b/frontend/src/utils/PFPutils/cropImage.ts index 40afbac24..992f840ff 100644 --- a/frontend/src/utils/PFPutils/cropImage.ts +++ b/frontend/src/utils/PFPutils/cropImage.ts @@ -14,11 +14,8 @@ function loadImage(src: string): Promise { }); } -/** - * Draws the selected crop area onto a fixed-size square canvas and returns - * it as a JPEG data URL, so every saved avatar is small and uniform - * regardless of the source photo's resolution. - */ +// Fixed output size so an avatar stays small and uniform whatever the source +// photo's resolution. export async function getCroppedImg( imageSrc: string, pixelCrop: PixelCrop, diff --git a/frontend/src/utils/PFPutils/pickImagePFP.ts b/frontend/src/utils/PFPutils/pickImagePFP.ts index c6002817b..4c3ae82f0 100644 --- a/frontend/src/utils/PFPutils/pickImagePFP.ts +++ b/frontend/src/utils/PFPutils/pickImagePFP.ts @@ -8,12 +8,9 @@ const EXTENSION_TO_MIME: Record = { jpeg: 'image/jpeg', }; -/** - * Opens the native OS file picker restricted to image files, reads the - * chosen file from disk, and resolves it as a base64 data URL that can be - * fed straight into , , or react-easy-crop. - * Resolves to null if the user cancels the dialog. - */ +// A data URL rather than a path, because the crop step reads the canvas back +// with toDataURL and a cross-origin asset:// source would taint it. Null means +// the user cancelled. export async function pickImageFile(): Promise { const selected = await open({ multiple: false, diff --git a/frontend/src/utils/__tests__/dateUtils.test.ts b/frontend/src/utils/__tests__/dateUtils.test.ts index d022436d8..eee6dfad9 100644 --- a/frontend/src/utils/__tests__/dateUtils.test.ts +++ b/frontend/src/utils/__tests__/dateUtils.test.ts @@ -1,9 +1,7 @@ import { getTimeAgo, groupImagesByYearMonthFromMetadata } from '../dateUtils'; import { Image, ImageMetadata } from '@/types/Media'; -/* ------------------------------------------------------------------ */ -/* getTimeAgo */ -/* ------------------------------------------------------------------ */ +// getTimeAgo describe('getTimeAgo', () => { beforeEach(() => { @@ -36,9 +34,7 @@ describe('getTimeAgo', () => { }); }); -/* ------------------------------------------------------------------ */ -/* groupImagesByYearMonthFromMetadata */ -/* ------------------------------------------------------------------ */ +// groupImagesByYearMonthFromMetadata const makeImage = (id: string, dateCreated: string | null): Image => ({ id, diff --git a/frontend/src/utils/durationUtils.ts b/frontend/src/utils/durationUtils.ts index d34b69b43..bccd9d0be 100644 --- a/frontend/src/utils/durationUtils.ts +++ b/frontend/src/utils/durationUtils.ts @@ -1,8 +1,5 @@ -/** - * Formats seconds as `h:mm:ss` when over an hour, otherwise `m:ss`. - * Negative and non-finite input (an unknown media duration reads as NaN or - * Infinity) normalises to `0:00` rather than rendering `NaN:NaN`. - */ +// `h:mm:ss` over an hour, else `m:ss`. Negative and non-finite input (an unknown +// duration reads NaN) normalises to `0:00` rather than rendering `NaN:NaN`. export const formatDuration = (seconds: number): string => { const safeSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0; const hours = Math.floor(safeSeconds / 3600); diff --git a/frontend/src/utils/imageFallback.ts b/frontend/src/utils/imageFallback.ts index 2417e1b21..da5d18c5f 100644 --- a/frontend/src/utils/imageFallback.ts +++ b/frontend/src/utils/imageFallback.ts @@ -2,13 +2,8 @@ import type { SyntheticEvent } from 'react'; export const PLACEHOLDER_IMAGE_SRC = '/placeholder.svg'; -/** - * Builds an `` onError handler that swaps in a fallback image exactly once - * and detaches itself, so a broken fallback cannot trigger an error loop. - * - * Centralizes the handler that was previously copy-pasted across the media and - * memories components (each with its own fallback asset). - */ +// The fallback has to be a local asset that cannot fail in turn: clearing +// img.onerror does not detach the React onError prop callers attach this with. export const createImageErrorHandler = (fallbackSrc: string = PLACEHOLDER_IMAGE_SRC) => (event: SyntheticEvent) => { diff --git a/frontend/src/utils/memories.ts b/frontend/src/utils/memories.ts index e765986a7..02f36d96d 100644 --- a/frontend/src/utils/memories.ts +++ b/frontend/src/utils/memories.ts @@ -1,9 +1,3 @@ -/** - * Memories utility functions. - * - * Pure formatting helpers for memory data. - */ - import { convertFileSrc } from '@tauri-apps/api/core'; import type { MemoryCard, @@ -97,31 +91,20 @@ const EVENT_TYPE_LABELS: Record = { export const formatEventType = (eventType: MemoryEventType): string => EVENT_TYPE_LABELS[eventType] ?? 'Memory'; -/** - * Build the subtitle line for a card: the memory's own subtitle when the - * curator set one, otherwise its date span. - */ +/** The curator's subtitle when it set one, otherwise the date span. */ export const formatMemorySubtitle = (memory: MemoryCard): string => memory.subtitle || formatDateRange(memory.period_start, memory.period_end) || formatMemoryDate(memory.surface_date); -/** - * One playable slide: a still, or a clip shot during the same span. - * - * A discriminated union rather than a widened image type — the viewer has to - * render two different elements and hold a video slide for its own length. - */ +// A union, not a widened image type: the viewer renders two different elements +// and holds a video slide for its own length. export type MemorySlide = | ({ kind: 'image' } & MemoryImage) | ({ kind: 'video' } & MemoryVideo); -/** - * Merge a memory's stills and clips into the order they were shot. - * - * They arrive as two arrays because they live in two tables, but sort_order - * is one sequence across both, so this is a merge rather than a concat. - */ +// Two arrays because they live in two tables, but sort_order is one sequence +// across both -- so this merges rather than concatenates. export const buildMemorySlides = ( memory: Pick | null | undefined, ): MemorySlide[] => { @@ -139,12 +122,8 @@ export const buildMemorySlides = ( return slides.sort((a, b) => a.sort_order - b.sort_order); }; -/** - * How long a slide is held before the story advances. - * - * A still gets the configured interval; a clip runs for its own length, so - * the story never cuts away mid-shot or lingers on a frozen last frame. - */ +// A clip is held for its recorded length rather than cut off mid-shot or left +// on a frozen last frame. A still gets the configured interval. export const slideDurationMs = ( slide: MemorySlide | undefined, photoDurationMs: number, diff --git a/frontend/src/utils/peopleQuery.ts b/frontend/src/utils/peopleQuery.ts index dee1a87ce..a76c5c121 100644 --- a/frontend/src/utils/peopleQuery.ts +++ b/frontend/src/utils/peopleQuery.ts @@ -23,10 +23,7 @@ const CONNECTOR_PATTERN = /\s*(,|\+|&|\band\b|\bor\b)\s*/i; const normalize = (value: string) => value.trim().replace(/\s+/g, ' '); -/** - * Split a free-text query into person-name fragments. - * Returns null for anything that isn't a multi-name query. - */ +/** Null for anything that is not a multi-name query. */ export function parsePeopleQuery(query: string): ParsedPeopleQuery | null { const normalized = normalize(query); if (!normalized) return null; @@ -64,11 +61,8 @@ function findClusterByName( }); } -/** - * Resolve a multi-name query against the known face clusters. - * Returns null unless at least two distinct people resolve, so single-name and - * ordinary text queries fall through to tag/semantic search untouched. - */ +// Null unless at least two distinct people resolve, so single-name and ordinary +// text queries fall through to tag/semantic search untouched. export function resolvePeopleQuery( query: string, clusters: Cluster[], diff --git a/frontend/src/utils/personUtils.ts b/frontend/src/utils/personUtils.ts index 9ab22e261..81a8fd75f 100644 --- a/frontend/src/utils/personUtils.ts +++ b/frontend/src/utils/personUtils.ts @@ -8,11 +8,8 @@ export function getPhotoCountText(count: number): string { return `${count} photo${count !== 1 ? 's' : ''}`; } -/** - * Formats selected people names into a readable title based on match mode. - * match_any: "Person A or Person B" (any one of them) - * match_all: "Person A & Person B" (all together) - */ +// match_any reads "A or B", match_all reads "A and B" -- the title has to say +// which query actually ran. export function formatPeopleTitle( names: string[], matchMode: 'match_any' | 'match_all', diff --git a/frontend/src/utils/tauriUtils.ts b/frontend/src/utils/tauriUtils.ts index 4504a870d..1b26626a4 100644 --- a/frontend/src/utils/tauriUtils.ts +++ b/frontend/src/utils/tauriUtils.ts @@ -1,8 +1,3 @@ -/** - * Utility functions for Tauri environment detection - */ - -// Type declarations for Tauri window properties declare global { interface Window { __TAURI_INTERNALS__?: unknown; diff --git a/frontend/src/utils/tunnel.ts b/frontend/src/utils/tunnel.ts index efaed2036..7d5b2528e 100644 --- a/frontend/src/utils/tunnel.ts +++ b/frontend/src/utils/tunnel.ts @@ -1,12 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; -/** - * The SSH reverse tunnel that makes the share server reachable off the LAN. - * - * One tunnel forwards the whole share port, so it serves every share rather - * than one album. It is owned by the Rust side, which closes it when PictoPy - * exits so an album is never left reachable from the internet. - */ +// One SSH tunnel forwards the whole share port, so it serves every share rather +// than one album. Rust owns it and closes it on exit. /** Open a tunnel to the share port, or return the URL of the one already up. */ export const startTunnel = (port: number): Promise => diff --git a/sync-microservice/app/config/settings.py b/sync-microservice/app/config/settings.py index b9e2053a3..dacf22080 100644 --- a/sync-microservice/app/config/settings.py +++ b/sync-microservice/app/config/settings.py @@ -1,7 +1,6 @@ from platformdirs import user_data_dir import os -# Model Exports Path MODEL_EXPORTS_PATH = "app/models/ONNX_Exports" PRIMARY_BACKEND_URL = "http://localhost:52123" SYNC_MICROSERVICE_URL = "http://localhost:52124" diff --git a/sync-microservice/app/core/lifespan.py b/sync-microservice/app/core/lifespan.py index 26b75a5c0..7e69ef8fd 100644 --- a/sync-microservice/app/core/lifespan.py +++ b/sync-microservice/app/core/lifespan.py @@ -27,7 +27,6 @@ async def lifespan(app: FastAPI): # Startup logger.info("Starting PictoPy Sync Microservice...") - # Check database connection logger.info("Checking database connection...") connection_timeout = 60 retry_interval = 5 diff --git a/sync-microservice/app/database/folders.py b/sync-microservice/app/database/folders.py index 6447445ca..3c8d8edd1 100644 --- a/sync-microservice/app/database/folders.py +++ b/sync-microservice/app/database/folders.py @@ -68,7 +68,6 @@ def db_check_database_connection() -> bool: try: conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() - # Check if folders table exists cursor.execute( """ SELECT name FROM sqlite_master @@ -99,11 +98,9 @@ def db_get_tagging_progress() -> List[FolderTaggingInfo]: cursor = conn.cursor() try: - # Pre-aggregate each media table by folder before joining, so a folder - # with M images and N videos never fans out to M*N intermediate rows. - # A video is "embedded" once tagged with no unembedded frames left -- a - # zero-frame (undecodable) video counts as embedded so it never stalls - # the bar. + # Pre-aggregated per table before joining, so M images and N videos never + # fan out to M*N rows. A zero-frame (undecodable) video counts as embedded + # so it never stalls the bar. cursor.execute( """ SELECT diff --git a/sync-microservice/app/logging/setup_logging.py b/sync-microservice/app/logging/setup_logging.py index f294506d3..613d2ec35 100644 --- a/sync-microservice/app/logging/setup_logging.py +++ b/sync-microservice/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,25 +153,20 @@ 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) - # 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", {}) @@ -243,7 +233,6 @@ 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" @@ -255,7 +244,6 @@ def emit(self, record: logging.LogRecord) -> None: record.msg = f"[{module_name}] {msg}" record.args = () - # Clear exception / stack info to avoid duplicate traces record.exc_info = None record.stack_info = None @@ -274,7 +262,6 @@ 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 @@ -292,7 +279,6 @@ 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 diff --git a/sync-microservice/app/routes/shutdown.py b/sync-microservice/app/routes/shutdown.py index 9e31ee42d..c40f69c00 100644 --- a/sync-microservice/app/routes/shutdown.py +++ b/sync-microservice/app/routes/shutdown.py @@ -53,7 +53,6 @@ async def shutdown(): logger.info("Shutdown request received for sync microservice") try: - # Stop the folder watcher first watcher_util_stop_folder_watcher() except Exception as e: logger.error(f"Error stopping folder watcher: {e}") diff --git a/sync-microservice/app/utils/watcher.py b/sync-microservice/app/utils/watcher.py index 902a50cf5..9e4925576 100644 --- a/sync-microservice/app/utils/watcher.py +++ b/sync-microservice/app/utils/watcher.py @@ -9,7 +9,6 @@ from app.config.settings import PRIMARY_BACKEND_URL from app.logging.setup_logging import get_sync_logger -# Configure third-party loggers logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING) logging.getLogger("watchfiles").setLevel(logging.WARNING) # Silence watchfiles logger @@ -38,10 +37,8 @@ def watcher_util_get_folder_id_if_watched(file_path: str) -> Optional[str]: Returns: Folder ID if the path is a watched folder, None otherwise """ - # Normalize the file path normalized_path = os.path.abspath(file_path) - # Check if this path matches any of our watched folders for folder_id, folder_path in watched_folders: if os.path.abspath(folder_path) == normalized_path: return folder_id @@ -62,14 +59,12 @@ def watcher_util_handle_file_changes(changes: set) -> None: # First pass - count changes and identify affected folders for change, file_path in changes: - # Process deletions if change == Change.deleted: deleted_folder_id = watcher_util_get_folder_id_if_watched(file_path) if deleted_folder_id: deleted_folder_ids.append(deleted_folder_id) continue - # Find affected folder closest_folder = watcher_util_find_closest_parent_folder( file_path, watched_folders ) @@ -77,11 +72,9 @@ def watcher_util_handle_file_changes(changes: set) -> None: folder_id, folder_path = closest_folder affected_folders[folder_path] = folder_id - # Process affected folders for folder_path, folder_id in affected_folders.items(): watcher_util_call_sync_folder_api(folder_id, folder_path) - # Handle deleted folders if deleted_folder_ids: logger.info(f"Processing {len(deleted_folder_ids)} deleted folders") watcher_util_call_delete_folders_api(deleted_folder_ids) @@ -101,17 +94,14 @@ def watcher_util_find_closest_parent_folder( Returns: Tuple of (folder_id, folder_path) if found, None otherwise """ - # Normalize the file path file_path = os.path.abspath(file_path) best_match = None longest_match_length = 0 for folder_id, folder_path in watched_folders: - # Normalize the folder path folder_path = os.path.abspath(folder_path) - # Check if this folder is a parent of the file if file_path.startswith(folder_path): # Ensure it's a proper parent (not just a prefix) if file_path == folder_path or file_path[len(folder_path)] == os.sep: @@ -204,7 +194,6 @@ def watcher_util_watcher_worker(folder_paths: List[str]) -> None: logger.debug("Detailed changes:\n %s", format_debug_changes(changes)) - # Process changes watcher_util_handle_file_changes(changes) except Exception as e: logger.error(f"Error in watcher worker: {e}") @@ -323,7 +312,6 @@ def watcher_util_stop_folder_watcher() -> None: logger.error(f"Error stopping watcher: {e}") finally: watcher_thread = None - # Clear state watched_folders = [] folder_id_map = {}