From 3c165d8d1c9f706a07a9c97f8eef1446b2f39fa0 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Sun, 9 Aug 2026 21:35:36 +0530 Subject: [PATCH 1/6] docs: strip comment slop and dead commented-out code Comments that only restate the identifier below them carry no information and push the code that does matter further apart. Removed across the frontend API wrappers, hooks and settings components, and the route-path comments in the backend that duplicated the decorator underneath (one had already drifted -- albums.py labelled a POST route as GET). Comments that explain *why* an implementation was chosen are kept. A handful of the longest were tightened rather than dropped, since the reasoning is the part worth reading. Dead code removed with it: app/routes/test.py was 173 lines entirely commented out and imported nowhere, plus a debug print, a commented-out formula, and two leftover console.log lines. The commented-out folder-permission test is restored and passing instead of deleted. Closes #1458 --- backend/app/models/ONNXSessionBase.py | 10 +- backend/app/routes/albums.py | 15 +- backend/app/routes/folders.py | 9 +- backend/app/routes/images.py | 2 - backend/app/routes/share.py | 4 - backend/app/routes/test.py | 173 ------------------ backend/app/schemas/album.py | 4 - backend/app/schemas/share.py | 4 - backend/app/utils/YOLO.py | 1 - backend/app/utils/images.py | 16 +- backend/app/utils/memory_curator.py | 8 - backend/app/utils/videos.py | 4 +- backend/tests/test_folders.py | 37 ++-- frontend/src/api/api-functions/albums.ts | 45 +---- frontend/src/api/api-functions/share.ts | 13 +- frontend/src/components/Media/MediaView.tsx | 2 - .../VideoPlayer/NetflixStylePlayer.tsx | 7 +- frontend/src/hooks/useFolderOperations.tsx | 20 -- frontend/src/hooks/useMemories.tsx | 12 +- frontend/src/hooks/useMutationFeedback.tsx | 40 +--- frontend/src/hooks/useUserPreferences.tsx | 19 -- frontend/src/layout/layout.tsx | 5 +- .../src/pages/SearchResults/SearchResults.tsx | 10 +- frontend/src/pages/SettingsPage/Settings.tsx | 4 - .../components/ApplicationControlsCard.tsx | 3 - .../SettingsPage/components/SettingsCard.tsx | 15 -- .../components/UserPreferencesCard.tsx | 3 - .../src/pages/__tests__/SettingsPage.test.tsx | 9 - frontend/src/store/hooks.ts | 7 - frontend/src/utils/PFPutils/cropImage.ts | 7 +- frontend/src/utils/PFPutils/pickImagePFP.ts | 9 +- frontend/src/utils/tauriUtils.ts | 5 - 32 files changed, 54 insertions(+), 468 deletions(-) delete mode 100644 backend/app/routes/test.py diff --git a/backend/app/models/ONNXSessionBase.py b/backend/app/models/ONNXSessionBase.py index fa3a39db3..a97ed0c96 100644 --- a/backend/app/models/ONNXSessionBase.py +++ b/backend/app/models/ONNXSessionBase.py @@ -71,13 +71,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..8e56cd458 100644 --- a/backend/app/routes/albums.py +++ b/backend/app/routes/albums.py @@ -61,7 +61,6 @@ 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.""" @@ -92,7 +91,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 +107,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 +154,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) @@ -198,7 +194,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 +242,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 +267,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 +316,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 +353,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 +380,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/folders.py b/backend/app/routes/folders.py index c27e789a2..25a0c8fd3 100644 --- a/backend/app/routes/folders.py +++ b/backend/app/routes/folders.py @@ -221,12 +221,9 @@ def add_folder(request: AddFolderRequest, app_state: State = Depends(get_state)) 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) - ): + # Read access is all indexing asks for, so a folder the user cannot + # write to is still perfectly usable. + if not os.access(request.folder_path, os.R_OK): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorResponse( diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index 1df634d05..887999f45 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -240,10 +240,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)) 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/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/YOLO.py b/backend/app/utils/YOLO.py index cf63d41dc..5d7da3021 100644 --- a/backend/app/utils/YOLO.py +++ b/backend/app/utils/YOLO.py @@ -106,7 +106,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 diff --git a/backend/app/utils/images.py b/backend/app/utils/images.py index 523118206..445a83836 100644 --- a/backend/app/utils/images.py +++ b/backend/app/utils/images.py @@ -178,13 +178,10 @@ 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. + # Only the ids that actually embedded. The rest stay + # isEmbedded=False and retry next pass -- cheap, since + # preprocessing just fails on PIL, and a file readable later + # (transient lock, restored backup) is not lost for good. db_mark_images_embedded(good_ids) elapsed = time.time() - start_time @@ -296,9 +293,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, diff --git a/backend/app/utils/memory_curator.py b/backend/app/utils/memory_curator.py index e7befbca3..a88aa091c 100644 --- a/backend/app/utils/memory_curator.py +++ b/backend/app/utils/memory_curator.py @@ -473,9 +473,7 @@ def _select_videos( return [] -# ############################## # Trigger 1: anniversary -# ############################## def _curate_anniversaries(context: _CurationContext) -> int: @@ -538,9 +536,7 @@ def _curate_anniversaries(context: _CurationContext) -> int: return generated -# ############################## # Trigger 2: import event -# ############################## def segment_by_time_and_place( @@ -669,9 +665,7 @@ def name_from_labels( return generated -# ############################## # Trigger 3: semantic event -# ############################## def group_event_occurrences( @@ -892,9 +886,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: diff --git a/backend/app/utils/videos.py b/backend/app/utils/videos.py index 1c209636d..52c74f412 100644 --- a/backend/app/utils/videos.py +++ b/backend/app/utils/videos.py @@ -455,9 +455,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/tests/test_folders.py b/backend/tests/test_folders.py index 18585de83..b0fe0b71e 100644 --- a/backend/tests/test_folders.py +++ b/backend/tests/test_folders.py @@ -241,24 +241,25 @@ 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 - - # 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) - - # assert response.status_code == 401 - # data = response.json() - # assert data["detail"]["success"] is False - # assert data["detail"]["error"] == "Permission denied" + @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 is rejected with 401.""" + mock_access.return_value = False + + request_data = { + "folder_path": temp_folder_structure["photos"], + "parent_folder_id": None, + "taggingCompleted": False, + } + + 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" @patch("app.routes.folders.folder_util_add_folder_tree") @patch("app.routes.folders.db_update_parent_ids_for_subtree") 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/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/Media/MediaView.tsx b/frontend/src/components/Media/MediaView.tsx index b72b6dfde..7b60e887b 100644 --- a/frontend/src/components/Media/MediaView.tsx +++ b/frontend/src/components/Media/MediaView.tsx @@ -166,10 +166,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/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/hooks/useFolderOperations.tsx b/frontend/src/hooks/useFolderOperations.tsx index cdd0f2c6a..8b34f7bb1 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,7 +119,6 @@ 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] }), @@ -145,7 +132,6 @@ export const useFolderOperations = () => { }, }); - // Apply feedback to the delete folder mutation useMutationFeedback(deleteFolderMutation, { showLoading: true, loadingMessage: 'Deleting folder', @@ -156,9 +142,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 +150,6 @@ export const useFolderOperations = () => { } }; - /** - * Delete a folder - */ const deleteFolder = (folderId: string) => { deleteFolderMutation.mutate(folderId); }; diff --git a/frontend/src/hooks/useMemories.tsx b/frontend/src/hooks/useMemories.tsx index 23aa30b7a..209b75bf6 100644 --- a/frontend/src/hooks/useMemories.tsx +++ b/frontend/src/hooks/useMemories.tsx @@ -96,14 +96,10 @@ 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. + * POST /generate returns once the run is *queued*, minutes before it runs, so + * invalidating on the response just refetches what is already on screen -- the + * reason Refresh used to look like it did nothing. This follows the run + * instead, refreshing as results land and once more when it settles. */ export const useRefreshMemories = () => { const queryClient = useQueryClient(); 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/useUserPreferences.tsx b/frontend/src/hooks/useUserPreferences.tsx index 8a6aedad5..98fa1e52a 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', @@ -57,7 +53,6 @@ export const useUserPreferences = () => { const writeEpoch = useRef(0); const readEpoch = useRef(0); - // Query for user preferences const preferencesQuery = usePictoQuery({ queryKey: ['userPreferences'], queryFn: () => { @@ -66,7 +61,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 +82,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', @@ -150,18 +143,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 +158,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/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..fdd23ad6e 100644 --- a/frontend/src/pages/SettingsPage/Settings.tsx +++ b/frontend/src/pages/SettingsPage/Settings.tsx @@ -10,10 +10,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/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..c3bb4855f 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. - */ +// Resolves a data URL rather than the path: react-easy-crop and canvas both +// need something an can load, and a Tauri file path is not that. Null +// means the user cancelled. export async function pickImageFile(): Promise { const selected = await open({ multiple: false, 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; From ce368e158b9225ec5a01bfcd1a87cf6f3649b937 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Sun, 9 Aug 2026 21:50:43 +0530 Subject: [PATCH 2/6] docs: restructure remaining comments to one or two lines The first pass filtered on whether a comment said anything, not on how long it took to say it, so blocks carrying real reasoning were left at four to ten lines. This compresses them to the one-or-two-line house style, keeping the argument in every case -- what was cut is wordiness, not content. Reasoning comments now use // or # rather than a JSDoc frame, which costs two lines before a word is written. Also caught in this pass, all missed by the first scan because it only looked for runs of three or more comment lines: - 117 ASCII banner blocks in backend/tests, the same decoration already removed from backend/app - 145 single-line labels that restate the statement below them (# Initialize logger, // Set all folders, # Sort by path) - 27 "Step N:" narration comments across three route and util modules Kept the labels that carry something the code does not: the FK-ordering note in conftest, the "proper parent, not just a prefix" guard in the watcher, the colour-spec example in the log formatter. --- backend/app/config/settings.py | 16 ++-- backend/app/database/albums.py | 11 +-- backend/app/database/faces.py | 9 --- backend/app/database/folders.py | 6 -- backend/app/database/image_embeddings.py | 1 - backend/app/database/images.py | 8 -- backend/app/database/memories.py | 12 +-- backend/app/database/metadata.py | 2 - backend/app/database/semantic_labels.py | 22 ++--- backend/app/database/videos.py | 7 +- backend/app/logging/setup_logging.py | 15 ---- backend/app/models/FaceDetector.py | 2 - backend/app/models/ONNXSessionBase.py | 7 +- backend/app/routes/albums.py | 2 - backend/app/routes/face_clusters.py | 6 -- backend/app/routes/folders.py | 19 ----- backend/app/routes/images.py | 3 - backend/app/routes/models.py | 2 - backend/app/routes/videos.py | 1 - backend/app/utils/ONNX.py | 3 - backend/app/utils/SigLIP.py | 18 ++--- backend/app/utils/YOLO.py | 6 -- .../app/utils/extract_location_metadata.py | 5 -- backend/app/utils/face_clusters.py | 41 ---------- backend/app/utils/folders.py | 5 -- backend/app/utils/hardware_detect.py | 1 - backend/app/utils/image_metadata.py | 2 - backend/app/utils/images.py | 27 +------ backend/app/utils/memory_curator.py | 36 +++------ backend/app/utils/memory_monitor.py | 2 - backend/app/utils/memory_scoring.py | 12 +-- backend/app/utils/network.py | 7 +- backend/app/utils/takeout_sidecar.py | 7 +- backend/app/utils/videos.py | 1 - backend/tests/conftest.py | 1 - backend/tests/test_albums.py | 5 -- backend/tests/test_albums_db.py | 12 --- backend/tests/test_connection.py | 6 -- backend/tests/test_embedding_pipeline.py | 6 +- backend/tests/test_face_clusters.py | 28 +------ backend/tests/test_face_quality.py | 4 - backend/tests/test_faces_db.py | 12 --- backend/tests/test_folders.py | 31 +------ backend/tests/test_images_db.py | 18 ----- backend/tests/test_memories_db.py | 22 ----- backend/tests/test_memories_route.py | 16 ---- backend/tests/test_memory_curator.py | 26 +----- backend/tests/test_memory_scoring.py | 22 ----- backend/tests/test_memory_signals_db.py | 16 ---- backend/tests/test_metadata.py | 8 -- backend/tests/test_models.py | 12 +-- backend/tests/test_onnx_session_base.py | 6 +- backend/tests/test_semantic_search_route.py | 17 +--- backend/tests/test_user_preferences.py | 6 -- backend/tests/test_video_capture_date.py | 2 - backend/tests/test_video_frames.py | 14 ---- backend/tests/test_videos.py | 12 --- backend/tests/test_yolo_mapping.py | 4 - frontend/src-tauri/src/main.rs | 6 +- frontend/src-tauri/src/services/tunnel.rs | 80 ++++++------------- frontend/src/api/api-functions/memories.ts | 5 +- .../components/Albums/ShareAlbumDialog.tsx | 6 +- .../BackgroundTasks/BackgroundTaskAlert.tsx | 5 +- .../LibraryProcessingIndicator.tsx | 5 +- frontend/src/components/Media/MediaView.tsx | 1 - .../Media/__tests__/ZoomableImage.test.tsx | 12 +-- .../Memories/ConvertMemoryToAlbumDialog.tsx | 5 +- .../src/components/Memories/MemoryCard.tsx | 7 +- .../components/Memories/MemoryFilmstrip.tsx | 5 +- .../components/Memories/MemoryStoryViewer.tsx | 5 +- .../__tests__/MemoryStoryViewer.test.tsx | 6 +- .../src/components/__tests__/Navbar.test.tsx | 3 - .../src/components/__tests__/Sidebar.test.tsx | 1 - frontend/src/constants/layout.ts | 9 +-- frontend/src/features/faceClustersSlice.ts | 2 - frontend/src/features/folderSelectors.ts | 5 -- frontend/src/features/folderSlice.ts | 7 -- frontend/src/features/memoriesSlice.ts | 8 +- .../__tests__/useFolderOperations.test.tsx | 6 +- .../__tests__/useUserPreferences.test.tsx | 9 +-- frontend/src/hooks/useFolderOperations.tsx | 6 +- .../src/hooks/useLibraryProcessingStatus.ts | 7 +- frontend/src/hooks/useMemories.tsx | 33 ++------ frontend/src/hooks/usePersistedSort.ts | 9 +-- frontend/src/hooks/useShareTunnel.ts | 16 +--- frontend/src/hooks/useStoryProgress.ts | 9 +-- frontend/src/hooks/useUserPreferences.tsx | 18 ++--- frontend/src/pages/Album/Album.tsx | 7 +- frontend/src/pages/Album/AlbumDetail.tsx | 2 - .../src/pages/ModelManager/InstalledTab.tsx | 2 - frontend/src/pages/SettingsPage/Settings.tsx | 1 - frontend/src/types/Folder.ts | 10 +-- frontend/src/types/Share.ts | 11 +-- frontend/src/utils/durationUtils.ts | 7 +- frontend/src/utils/imageFallback.ts | 8 +- frontend/src/utils/memories.ts | 35 ++------ frontend/src/utils/peopleQuery.ts | 12 +-- frontend/src/utils/personUtils.ts | 7 +- frontend/src/utils/tunnel.ts | 9 +-- sync-microservice/app/config/settings.py | 1 - sync-microservice/app/core/lifespan.py | 1 - sync-microservice/app/database/folders.py | 9 +-- .../app/logging/setup_logging.py | 14 ---- sync-microservice/app/routes/shutdown.py | 1 - sync-microservice/app/utils/watcher.py | 12 --- 105 files changed, 176 insertions(+), 899 deletions(-) diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index 259b6174d..2ad65d919 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 2eb1ab021..82891152e 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -11,7 +11,6 @@ ) from app.logging.setup_logging import get_logger -# Initialize logger logger = get_logger(__name__) # Type definitions @@ -86,7 +85,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)" @@ -98,7 +96,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 ( @@ -182,7 +179,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, @@ -253,14 +249,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 @@ -448,7 +442,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""" @@ -484,7 +477,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 a97ed0c96..f335b8358 100644 --- a/backend/app/models/ONNXSessionBase.py +++ b/backend/app/models/ONNXSessionBase.py @@ -34,10 +34,9 @@ def __init__(self, model_path: str): self._session_registered = False self._session: onnxruntime.InferenceSession | None = None self._lock = threading.Lock() - # Serializes session.run() on the shared session. The DirectML EP - # faults (0xC0000005) if two threads run the same session at once, - # which the parallel image+video semantic searches do. Separate from - # _lock so inference never blocks on session creation/close. + # DirectML faults (0xC0000005) if two threads run one session at once, + # which parallel image+video search does. Separate from _lock so inference + # never blocks on session creation or close. self._inference_lock = threading.Lock() def _create_session(self) -> onnxruntime.InferenceSession: diff --git a/backend/app/routes/albums.py b/backend/app/routes/albums.py index 8e56cd458..8be30448a 100644 --- a/backend/app/routes/albums.py +++ b/backend/app/routes/albums.py @@ -67,7 +67,6 @@ def get_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"] @@ -166,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) 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 25a0c8fd3..02d1b6bbf 100644 --- a/backend/app/routes/folders.py +++ b/backend/app/routes/folders.py @@ -64,7 +64,6 @@ from app.utils.face_clusters import cluster_util_face_clusters_sync from app.utils.API import API_util_restart_sync_microservice_watcher -# Initialize logger logger = get_logger(__name__) router = APIRouter() @@ -100,18 +99,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: @@ -173,7 +169,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)) @@ -183,7 +178,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() @@ -195,7 +189,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( @@ -214,7 +207,6 @@ 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( @@ -235,7 +227,6 @@ def add_folder(request: AddFolderRequest, app_state: State = Depends(get_state)) 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, @@ -246,12 +237,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, @@ -259,10 +248,8 @@ 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 executor: ProcessPoolExecutor = app_state.executor executor.submit(post_folder_add_sequence, request.folder_path, root_folder_id) @@ -432,20 +419,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 ) @@ -453,7 +437,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 ] @@ -465,7 +448,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, @@ -512,7 +494,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 887999f45..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"], @@ -255,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/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/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 5d7da3021..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 = [] @@ -253,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, @@ -278,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 445a83836..3392a8d4b 100644 --- a/backend/app/utils/images.py +++ b/backend/app/utils/images.py @@ -33,7 +33,6 @@ logger = get_logger(__name__) -# GPS EXIF tag constant GPS_INFO_TAG = 34853 logger = logging.getLogger(__name__) @@ -49,28 +48,22 @@ 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 = [] all_folder_ids = [] - # 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 ) @@ -80,11 +73,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) @@ -97,12 +88,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 @@ -178,10 +167,8 @@ def image_util_process_unembedded_images() -> None: embedded_count += len(good_arrays) if good_ids: - # Only the ids that actually embedded. The rest stay - # isEmbedded=False and retry next pass -- cheap, since - # preprocessing just fails on PIL, and a file readable later - # (transient lock, restored backup) is not lost for good. + # 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 @@ -208,28 +195,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() @@ -265,7 +245,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}") @@ -359,7 +338,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") @@ -446,7 +424,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 a88aa091c..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", @@ -624,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, @@ -985,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..e6312bd81 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 ranks every video below every photo. 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 52c74f412..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] = [] 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 b0fe0b71e..bed0568e8 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) return app @@ -154,17 +149,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") @@ -201,7 +192,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() @@ -367,9 +357,7 @@ def test_add_folder_background_processing_called( app_state = client.app.state 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): @@ -450,13 +438,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): @@ -551,9 +536,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): @@ -625,9 +608,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( @@ -645,7 +626,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" @@ -684,9 +664,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.""" @@ -734,9 +712,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: @@ -1204,9 +1180,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") @@ -1337,7 +1311,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..bcbe912c5 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; scattered ones use +# orthogonal bases, where mean cosine is 1/sqrt(N) -- eight clear the gate, two don't. 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..70eb58a20 100644 --- a/frontend/src-tauri/src/services/tunnel.rs +++ b/frontend/src-tauri/src/services/tunnel.rs @@ -1,13 +1,8 @@ -//! Exposing the share server beyond the LAN through an SSH reverse tunnel. +//! Exposes 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. +//! Uses the platform's own ssh rather than bundling a ~40MB binary pinned from +//! unversioned URLs. The child is owned here so it dies with the app -- an +//! orphaned tunnel leaves an album reachable from the internet. use std::path::PathBuf; use std::sync::Mutex; @@ -36,11 +31,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. A fallback matters because a provider can +/// fail while looking healthy: one issued URLs for a tunnel it never routed to. const PROVIDERS: [Provider; 1] = [Provider { name: "localhost.run", destination: "nokey@localhost.run", @@ -48,10 +40,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 +64,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 +98,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 +119,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 +147,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 +169,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 +324,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 +436,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/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/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 7b60e887b..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, 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/__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..1b0429f30 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: cards keep a near-constant width and +// the column count follows the window, so resizing reflows instead of stretching. 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 8b34f7bb1..54eb86115 100644 --- a/frontend/src/hooks/useFolderOperations.tsx +++ b/frontend/src/hooks/useFolderOperations.tsx @@ -123,10 +123,8 @@ export const useFolderOperations = () => { 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'] }); }, 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 209b75bf6..1c6a1e7c9 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 before a run is visible: /generate returns at +// queue time, before the worker writes 'running', so status alone never polls. export const useMemoryStatus = ( enabled: boolean = true, forcePolling: boolean = false, @@ -95,12 +84,8 @@ export const useMemoryStatus = ( ); }; -/** - * POST /generate returns once the run is *queued*, minutes before it runs, so - * invalidating on the response just refetches what is already on screen -- the - * reason Refresh used to look like it did nothing. This follows the run - * instead, refreshing 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); @@ -138,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/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..9542db59c 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: a start still in flight already has a killable child, and + // checking first would read null and skip the stop. A no-op when idle. 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 98fa1e52a..e7d32618d 100644 --- a/frontend/src/hooks/useUserPreferences.tsx +++ b/frontend/src/hooks/useUserPreferences.tsx @@ -46,10 +46,8 @@ 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. + // Counts reads rather than timing them: a read that starts first can still + // finish last, and describes the server from before the write either way. const writeEpoch = useRef(0); const readEpoch = useRef(0); @@ -97,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; 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/SettingsPage/Settings.tsx b/frontend/src/pages/SettingsPage/Settings.tsx index fdd23ad6e..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'; 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/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..eb87ca696 100644 --- a/frontend/src/utils/imageFallback.ts +++ b/frontend/src/utils/imageFallback.ts @@ -2,13 +2,7 @@ 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). - */ +// Detaches itself after the first swap, so a broken fallback cannot loop. 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..abfa3821a 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 runs for its own length, so the story never cuts away mid-shot or +// lingers 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/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 = {} From 330a6763c49905c0216cc02b232cb71fde8d7898 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Sun, 9 Aug 2026 21:52:58 +0530 Subject: [PATCH 3/6] docs: collapse the last banner comments and tunnel module header Four dash-rule banners in the frontend test files, which the earlier sweep missed by only matching # and = rules, and the tunnel module header down from four lines to three. --- frontend/src-tauri/src/services/tunnel.rs | 8 +++----- frontend/src/lib/__tests__/utils.test.ts | 8 ++------ frontend/src/utils/__tests__/dateUtils.test.ts | 8 ++------ 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/frontend/src-tauri/src/services/tunnel.rs b/frontend/src-tauri/src/services/tunnel.rs index 70eb58a20..ec8139ede 100644 --- a/frontend/src-tauri/src/services/tunnel.rs +++ b/frontend/src-tauri/src/services/tunnel.rs @@ -1,8 +1,6 @@ -//! Exposes the share server beyond the LAN through an SSH reverse tunnel. -//! -//! Uses the platform's own ssh rather than bundling a ~40MB binary pinned from -//! unversioned URLs. The child is owned here so it dies with the app -- an -//! orphaned tunnel leaves an album reachable from the internet. +//! 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; 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/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, From f6f35efb8919cd2eb4bbb79de28febe40172aa65 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Sun, 9 Aug 2026 22:03:57 +0530 Subject: [PATCH 4/6] fix: require traversal as well as read permission on an added folder CodeRabbit caught that the comment this PR put on the folder permission check was wrong. It read "Read access is all indexing asks for", but folder_util_add_folder_tree calls os.walk, and on POSIX a directory needs the search bit to be descended into, not just the read bit. Verified: a directory at r-- passes os.access(R_OK) and yields only its top level from os.walk, so the folder would be accepted and everything beneath it silently skipped. Checking R_OK | X_OK rejects it up front instead. The check itself is unchanged from before this PR -- the commented-out os.X_OK line removed earlier was hinting at exactly this. The revived test now asserts the mask, which return_value = False alone could never catch. --- backend/app/routes/folders.py | 8 ++++---- backend/tests/test_folders.py | 9 +++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/app/routes/folders.py b/backend/app/routes/folders.py index 02d1b6bbf..c2ef8cb68 100644 --- a/backend/app/routes/folders.py +++ b/backend/app/routes/folders.py @@ -213,15 +213,15 @@ def add_folder(request: AddFolderRequest, app_state: State = Depends(get_state)) f"Error: '{request.folder_path}' is not a valid directory." ) - # Read access is all indexing asks for, so a folder the user cannot - # write to is still perfectly usable. - if not os.access(request.folder_path, os.R_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(), ) diff --git a/backend/tests/test_folders.py b/backend/tests/test_folders.py index bed0568e8..484536ca7 100644 --- a/backend/tests/test_folders.py +++ b/backend/tests/test_folders.py @@ -235,11 +235,12 @@ def test_add_folder_invalid_path(self, client): def test_add_folder_permission_denied( self, mock_access, client, temp_folder_structure ): - """A folder the process cannot read is rejected with 401.""" + """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": temp_folder_structure["photos"], + "folder_path": folder_path, "parent_folder_id": None, "taggingCompleted": False, } @@ -251,6 +252,10 @@ def test_add_folder_permission_denied( 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") @patch("app.routes.folders.db_find_parent_folder_id") From 79aef67f0e95f0a7d160bb1d07bee79dd945fd45 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Tue, 11 Aug 2026 05:39:19 +0530 Subject: [PATCH 5/6] fix: correct the reason given for returning a data URL from the picker Reviewing my own rewrites, this one repeated the mistake CodeRabbit caught in the folder permission check: a justification I inferred instead of read. It claimed a Tauri file path is not something an can load. It is, via convertFileSrc, which utils/memories.ts and NetflixStylePlayer both rely on. The real reason is downstream: pickImageFile feeds avatarCropDialog, which calls getCroppedImg, which reads the canvas back with toDataURL. An asset:// source would taint the canvas and make that throw. A data URL is same-origin, so it cannot. --- frontend/src/utils/PFPutils/pickImagePFP.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/utils/PFPutils/pickImagePFP.ts b/frontend/src/utils/PFPutils/pickImagePFP.ts index c3bb4855f..4c3ae82f0 100644 --- a/frontend/src/utils/PFPutils/pickImagePFP.ts +++ b/frontend/src/utils/PFPutils/pickImagePFP.ts @@ -8,9 +8,9 @@ const EXTENSION_TO_MIME: Record = { jpeg: 'image/jpeg', }; -// Resolves a data URL rather than the path: react-easy-crop and canvas both -// need something an can load, and a Tauri file path is not that. Null -// means the user cancelled. +// 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, From f7d2b3b4097b42b0d2521260145088878b0f0fbd Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Tue, 11 Aug 2026 12:05:42 +0530 Subject: [PATCH 6/6] docs: correct comments that stated the wrong mechanism An independent review of every comment this branch adds or rewords found ten that assert something the code does not do. Four are regressions from this branch's compression, six were already inaccurate upstream and were carried forward when the comment was reworded. - test_memory_curator: the gate uses mean pairwise cosine, which is 0 for an orthogonal basis at any N, so scattered sets are rejected. The old note described centroid cosine (1/sqrt(N)) and drew the opposite conclusion. - useMemories: /generate writes 'running' before it returns. forcePolling exists because refetchInterval reads a status cache that still says 'complete'. - useUserPreferences: writeEpoch counts writes, not reads. - tunnel.rs: PROVIDERS holds one entry. Restore the note that srv.us is the intended second, pending a key of its own. - imageFallback: clearing img.onerror does not detach a React onError prop, so nothing detaches and the fallback has to be an asset that cannot fail. - ONNXSessionBase: get_session takes _lock before inference does, so inference can block. The split keeps a long run from holding up create and close. - memory_scoring: composite_score renormalizes, so a zero does not put every video below every photo. - layout.ts, useShareTunnel, memories.ts: three more claims narrowed to what the code actually guarantees. Co-Authored-By: Claude Opus 5 --- backend/app/models/ONNXSessionBase.py | 4 ++-- backend/app/utils/memory_scoring.py | 2 +- backend/tests/test_memory_curator.py | 4 ++-- frontend/src-tauri/src/services/tunnel.rs | 4 ++-- frontend/src/constants/layout.ts | 4 ++-- frontend/src/hooks/useMemories.tsx | 4 ++-- frontend/src/hooks/useShareTunnel.ts | 4 ++-- frontend/src/hooks/useUserPreferences.tsx | 4 ++-- frontend/src/utils/imageFallback.ts | 3 ++- frontend/src/utils/memories.ts | 4 ++-- 10 files changed, 19 insertions(+), 18 deletions(-) diff --git a/backend/app/models/ONNXSessionBase.py b/backend/app/models/ONNXSessionBase.py index f335b8358..f5400e824 100644 --- a/backend/app/models/ONNXSessionBase.py +++ b/backend/app/models/ONNXSessionBase.py @@ -35,8 +35,8 @@ def __init__(self, model_path: str): self._session: onnxruntime.InferenceSession | None = None self._lock = threading.Lock() # DirectML faults (0xC0000005) if two threads run one session at once, - # which parallel image+video search does. Separate from _lock so inference - # never blocks on session creation or close. + # 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: diff --git a/backend/app/utils/memory_scoring.py b/backend/app/utils/memory_scoring.py index e6312bd81..c53f81576 100644 --- a/backend/app/utils/memory_scoring.py +++ b/backend/app/utils/memory_scoring.py @@ -57,7 +57,7 @@ ALWAYS_AVAILABLE = frozenset({"favourite", "known_people", "face_presence", "in_album"}) # For videos these are absent, not zero: nothing detects faces in one and it -# cannot join an album. Scoring them zero ranks every video below every photo. +# 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/tests/test_memory_curator.py b/backend/tests/test_memory_curator.py index bcbe912c5..3ef5ddd04 100644 --- a/backend/tests/test_memory_curator.py +++ b/backend/tests/test_memory_curator.py @@ -17,8 +17,8 @@ T0 = datetime(2024, 7, 26, 10, 0, 0) REFERENCE = "2026-07-26" -# One shared direction stands in for a consistent set; scattered ones use -# orthogonal bases, where mean cosine is 1/sqrt(N) -- eight clear the gate, two don't. +# 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) diff --git a/frontend/src-tauri/src/services/tunnel.rs b/frontend/src-tauri/src/services/tunnel.rs index ec8139ede..545d729e0 100644 --- a/frontend/src-tauri/src/services/tunnel.rs +++ b/frontend/src-tauri/src/services/tunnel.rs @@ -29,8 +29,8 @@ struct Provider { url_suffix: &'static str, } -/// Tried in order until one answers. A fallback matters because a provider can -/// fail while looking healthy: one issued URLs for a tunnel it never routed 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", diff --git a/frontend/src/constants/layout.ts b/frontend/src/constants/layout.ts index 1b0429f30..1a8aebdb7 100644 --- a/frontend/src/constants/layout.ts +++ b/frontend/src/constants/layout.ts @@ -1,4 +1,4 @@ -// Intrinsic rather than breakpoint-driven: cards keep a near-constant width and -// the column count follows the window, so resizing reflows instead of stretching. +// 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/hooks/useMemories.tsx b/frontend/src/hooks/useMemories.tsx index 1c6a1e7c9..3c7831821 100644 --- a/frontend/src/hooks/useMemories.tsx +++ b/frontend/src/hooks/useMemories.tsx @@ -63,8 +63,8 @@ export const useMemory = (memoryId?: string) => { }); }; -// `forcePolling` covers the gap before a run is visible: /generate returns at -// queue time, before the worker writes 'running', so status alone never polls. +// `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, diff --git a/frontend/src/hooks/useShareTunnel.ts b/frontend/src/hooks/useShareTunnel.ts index 9542db59c..a79c3cfe3 100644 --- a/frontend/src/hooks/useShareTunnel.ts +++ b/frontend/src/hooks/useShareTunnel.ts @@ -54,8 +54,8 @@ export const useShareTunnel = (): ShareTunnel => { const close = useCallback(async (): Promise => { revision.current += 1; - // Unconditional: a start still in flight already has a killable child, and - // checking first would read null and skip the stop. A no-op when idle. + // 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/useUserPreferences.tsx b/frontend/src/hooks/useUserPreferences.tsx index e7d32618d..ca9584bdc 100644 --- a/frontend/src/hooks/useUserPreferences.tsx +++ b/frontend/src/hooks/useUserPreferences.tsx @@ -46,8 +46,8 @@ export const useUserPreferences = () => { // Non-zero from the moment a write is queued until it settles. const pendingWrites = useRef(0); - // Counts reads rather than timing them: a read that starts first can still - // finish last, and describes the server from before the write either way. + // 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); diff --git a/frontend/src/utils/imageFallback.ts b/frontend/src/utils/imageFallback.ts index eb87ca696..da5d18c5f 100644 --- a/frontend/src/utils/imageFallback.ts +++ b/frontend/src/utils/imageFallback.ts @@ -2,7 +2,8 @@ import type { SyntheticEvent } from 'react'; export const PLACEHOLDER_IMAGE_SRC = '/placeholder.svg'; -// Detaches itself after the first swap, so a broken fallback cannot loop. +// 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 abfa3821a..02f36d96d 100644 --- a/frontend/src/utils/memories.ts +++ b/frontend/src/utils/memories.ts @@ -122,8 +122,8 @@ export const buildMemorySlides = ( return slides.sort((a, b) => a.sort_order - b.sort_order); }; -// A clip runs for its own length, so the story never cuts away mid-shot or -// lingers on a frozen last frame. A still gets the configured interval. +// 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,