Filter NAS photos by EXIF-aware orientation with persistent dimension cache
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user