Files
mule-image/backend/app/models/photos.py
root 339e1be510 feat: hide-from-views flag on folders
Adds a per-folder "hide from views" toggle so noisy subtrees
(screenshots, WhatsApp dumps, work archives) can be excluded from
cross-cutting views without losing indexing. Photos under a hidden
folder are still scanned, thumbnailed, embedded, OCR'd, face-
extracted — they just stop appearing in All Photos, Rated, Colors,
Map, Tags, People, Search, Duplicates, and the sidebar counts.
Navigating directly into the folder still shows every photo.

Schema (migration 0007_folder_hidden):
- folders.is_hidden   user-set toggle, default false
- photos.is_hidden    denormalized effective flag (true iff any
                      ancestor folder is hidden), indexed so cross-
                      cutting queries stay on the existing planner
                      paths

The denorm is maintained by two paths:
- The scanner walks the ancestry chain on insert, with a per-scan
  memoized cache so each folder is resolved once per scan.
- POST /api/v1/folders/{id}/hide flips folders.is_hidden and runs a
  WITH RECURSIVE CTE to recompute every folder's effective state in
  one query, then bulk-updates photos WHERE IS DISTINCT FROM. Runs
  in ~10 ms on a 13k-photo library.

Filters added (cross-cutting queries):
- /library/stats — every sidebar badge via a shared `visible` filter
- /photos (list) — only when neither folder_id nor heap_id is set;
  folder browse and heap browse always show everything
- /photos/map
- /library/duplicates/groups
- /folders/tree photo_count subquery
- /tags count_subq (drives Tags + People sidebar counts)
- services/duplicates.regroup_duplicates (so hidden dupes never
  contaminate the Duplicates view)
- services/search.hybrid_search — both semantic (pgvector) and FTS
  legs join photos so rankings don't include hidden results

Intentionally NOT filtered:
- /photos?folder_id=X and /photos?heap_id=X (user-intentional browse)
- /library/maintenance/pipeline-stats (tracks real worker state)
- cleanup service (disk-level ops, not views)

Frontend:
- sourceFolders.setHidden(id, hidden) API client method
- FolderTreeNode.is_hidden carried through the tree into TreeItem
- LeftSidebar kebab menu: "Hide from views" / "Show in views" with a
  mutation that invalidates folders, photos, stats, and tags caches
- Hidden folder rows swap the Folder icon for EyeOff and render the
  label italic/muted so the state is visible at a glance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:55:25 +02:00

111 lines
5.0 KiB
Python

"""
Photo model definition
"""
from sqlalchemy import Column, String, Integer, Float, Boolean, DateTime, ForeignKey, Text, Index
from sqlalchemy.sql import func
from datetime import datetime
import uuid
from app.database import Base
class Photo(Base):
__tablename__ = 'photos'
# Primary key
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
# File information
filepath = Column(String, unique=True, nullable=False)
filename = Column(String, nullable=False)
folder_id = Column(String, ForeignKey('folders.id'))
file_hash = Column(String, index=True) # SHA-256 hash for duplicate detection
# Media information
media_type = Column(String, nullable=False) # 'photo' | 'video' | 'raw' | 'heic'
original_format = Column(String) # 'CR3', 'NEF', 'HEIC', 'MP4', 'JPEG', etc.
width = Column(Integer)
height = Column(Integer)
file_size = Column(Integer)
# Timestamps
taken_at = Column(DateTime) # from EXIF DateTimeOriginal, fallback to file mtime
taken_at_source = Column(String) # 'exif' | 'filesystem' | 'manual'
added_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())
# Discard status. The DB column names stay is_trashed/trashed_at to avoid
# a migration; only the Python attribute name reflects the rename.
is_discarded = Column('is_trashed', Boolean, default=False)
discarded_at = Column('trashed_at', DateTime)
# "Hidden from views" — materialized from Folder.is_hidden walking
# the ancestry chain. True iff any ancestor folder (including the
# photo's direct folder) is hidden. Cross-cutting queries filter
# `AND NOT is_hidden`; per-folder browses ignore the flag so the
# user can still open a hidden folder and see its contents. The
# column is maintained by two places: the scanner sets it on new
# rows, and POST /folders/{id}/hide recomputes it on toggle.
is_hidden = Column(Boolean, nullable=False, default=False, server_default='false', index=True)
# Thumbnail paths
thumb_small = Column(String) # path to 240px thumb
thumb_medium = Column(String) # path to 640px thumb
thumb_large = Column(String) # path to 1280px thumb
# Processing status
processing_status = Column(String, default='pending') # 'pending' | 'processing' | 'completed' | 'failed'
processing_error = Column(Text)
# Metadata
exif_json = Column(Text) # full EXIF/XMP blob as JSON
# GPS coordinates extracted from EXIF, in signed decimal degrees
# (S latitude / W longitude are negative). Stored as first-class columns
# so the Map view and any future location filters can query/index them
# without parsing exif_json on every request.
latitude = Column(Float)
longitude = Column(Float)
# User-editable fields
user_title = Column(String)
user_notes = Column(Text)
rating = Column(Integer, default=0) # 0-5 stars
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
# Note: is_rejected was merged into is_discarded (a single soft "discarded"
# concept). is_picked was unified with active-heap membership — picking a
# photo just means adding it to the active heap. Both DB columns may still
# exist on legacy installs but are no longer read or written.
# Duplicate detection.
#
# - file_hash (above): SHA-256 of the raw bytes. Catches byte-identical
# copies but not visually-identical re-encodes / resizes / screenshots.
# - phash: 16-char hex of a 64-bit perceptual hash, computed by the
# thumbs worker from the decoded original frame. Robust to resize and
# re-compression — this is what actually identifies "the same photo
# saved twice with different JPEG quality".
# - duplicate_group_id: shared by every photo in the same duplicate
# cluster. Maintained by app.services.duplicates.regroup_duplicates,
# not on individual writes — recomputed in batches after scans / on
# demand from the Settings panel.
# - is_duplicate: derived boolean (group_id IS NOT NULL). Kept as a real
# column so the existing PhotoThumbnail badge and /library/stats
# duplicates count don't have to change.
is_duplicate = Column(Boolean, default=False)
phash = Column(String(16), index=True)
duplicate_group_id = Column(String, index=True)
# Live photo support
live_photo_video_id = Column(String, ForeignKey('photos.id'))
# Indexes for performance
__table_args__ = (
Index('ix_photos_taken_at', 'taken_at'),
Index('ix_photos_folder_id', 'folder_id'),
Index('ix_photos_is_trashed', 'is_trashed'),
Index('ix_photos_rating', 'rating'),
Index('ix_photos_color_label', 'color_label'),
Index('ix_photos_media_type', 'media_type'),
Index('ix_photos_processing_status', 'processing_status'),
Index('ix_photos_lat_lon', 'latitude', 'longitude'),
)