Filter NAS photos by EXIF-aware orientation with persistent dimension cache

This commit is contained in:
Pigway
2026-10-05 10:38:20 +08:00
parent 0dca08ea18
commit bf349c6ca8
10 changed files with 368 additions and 6 deletions
@@ -0,0 +1,38 @@
"""EXIF-aware dimensions without decoding original photo pixels."""
import os
import stat
from pathlib import Path
from PIL import Image, UnidentifiedImageError
def dimensions(root: Path, path: Path):
parent_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
try:
parts = path.relative_to(root).parts
for part in parts[:-1]:
child = os.open(
part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent_fd
)
os.close(parent_fd)
parent_fd = child
fd = os.open(
parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent_fd
)
with os.fdopen(fd, "rb") as stream:
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
return None
with Image.open(stream) as image:
width, height = image.size
if image.getexif().get(274, 1) in (5, 6, 7, 8):
width, height = height, width
return width, height
except (OSError, ValueError, UnidentifiedImageError, Image.DecompressionBombError):
return None
finally:
os.close(parent_fd)
def orientation(width, height):
return "landscape" if width > height else "portrait" if height > width else "square"
@@ -18,6 +18,7 @@ from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import StrictModel
from pigway_frame_flow.providers.photo_names import path_caption
from pigway_frame_flow.providers.photo_orientation import dimensions, orientation
from pigway_frame_flow.providers.synology_photos import SynologyPhotos
from pigway_frame_flow.settings import secret
from pigway_frame_flow.storage.database import encode
@@ -33,6 +34,7 @@ class PhotoSourceInput(StrictModel):
synology_previews: bool = False
organized_only: bool = False
folder_caption: bool = False
orientation: Literal["auto", "landscape", "portrait"] | None = None
people_filter: bool = False
person_ids: list[int] = Field(default_factory=list, max_length=50)
person_match: Literal["any", "all"] = "any"
@@ -50,6 +52,9 @@ class PhotoItem(StrictModel):
name: str
caption: dict[str, str] | None = None
provider_preview: bool = False
orientation: Literal["landscape", "portrait", "square"] | None = None
width: int | None = None
height: int | None = None
class PhotoCatalog(StrictModel):
@@ -96,6 +101,11 @@ class PhotoSources:
return PhotoSourceInput.model_validate_json(row["payload"])
def save(self, source_id, source):
if source.orientation and source.kind != "local":
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Orientation filtering requires a local photo source",
)
if source.people_filter and source.kind != "local":
raise FlowError(
E.INVALID_REQUEST,
@@ -250,6 +260,13 @@ class PhotoSources:
else None
)
items = []
sizes = (
{r["path"]: r for r in self.db.all("SELECT * FROM photo_dimensions")}
if source.orientation
else {}
)
size_updates = []
wanted = "portrait" if source.orientation == "auto" else source.orientation
if source.kind == "local":
directory = self.local_directory(source.location)
root = self.settings.photo_root.resolve()
@@ -292,8 +309,45 @@ class PhotoSources:
relative = path.relative_to(root).as_posix()
if allowed is not None and relative not in allowed:
continue
info = {}
if wanted:
key = str(path)
stamp = path.stat()
cached_size = sizes.get(key)
if (
cached_size
and cached_size["mtime"] == stamp.st_mtime_ns
and cached_size["bytes"] == stamp.st_size
):
width, height = (
cached_size["width"],
cached_size["height"],
)
else:
size = dimensions(root, path)
if size is None:
continue
width, height = size
size_updates.append(
(
key,
stamp.st_mtime_ns,
stamp.st_size,
width,
height,
)
)
direction = orientation(width, height)
if direction != wanted:
continue
info = {
"width": width,
"height": height,
"orientation": direction,
}
items.append(
{
**info,
"id": relative,
"name": name,
"provider_preview": preview,
@@ -358,6 +412,12 @@ class PhotoSources:
break
except ElementTree.ParseError:
raise FlowError(E.INVALID_REQUEST, "Invalid WebDAV catalog") from None
if size_updates:
with self.db.transaction() as connection:
connection.executemany(
"INSERT OR REPLACE INTO photo_dimensions(path,mtime,bytes,width,height) VALUES(?,?,?,?,?)",
size_updates,
)
self.cache[source_id] = (time.monotonic(), items)
return items