2026-10-05 00:52:39 +08:00
"""Read-only image catalogs; provider credentials never enter SQLite."""
import base64
import json
2026-10-17 23:28:04 +08:00
import logging
2026-10-05 00:52:39 +08:00
import os
import posixpath
import random
import stat
import time
from pathlib import Path
from typing import Literal
from urllib.parse import unquote , urljoin , urlsplit
from xml.etree import ElementTree
from pydantic import Field
2026-10-20 18:50:43 +08:00
from pigway_frame_flow.core.photo_preferences import eligible
2026-10-05 00:52:39 +08:00
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import StrictModel
2026-10-05 01:36:05 +08:00
from pigway_frame_flow.providers.photo_names import path_caption
2026-10-05 10:38:20 +08:00
from pigway_frame_flow.providers.photo_orientation import dimensions , orientation
2026-10-05 08:43:07 +08:00
from pigway_frame_flow.providers.synology_photos import SynologyPhotos
2026-10-06 08:47:03 +08:00
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS
2026-10-05 00:52:39 +08:00
from pigway_frame_flow.settings import secret
from pigway_frame_flow.storage.database import encode
class PhotoSourceInput ( StrictModel ):
name : str = Field ( min_length = 1 , max_length = 120 )
2026-10-16 22:31:13 +08:00
kind : Literal [
2026-10-24 00:38:00 +08:00
"local" ,
"synology-photos" ,
"webdav" ,
"google-drive" ,
"alipan" ,
"baidu" ,
"uploads" ,
2026-10-16 22:31:13 +08:00
]
2026-10-05 00:52:39 +08:00
location : str = Field ( min_length = 1 , max_length = 2048 )
2026-10-18 15:30:38 +08:00
nas_root_path : str | None = Field ( default = None , max_length = 2048 , pattern = r "^/" )
2026-10-05 00:52:39 +08:00
recursive : bool = True
enabled : bool = True
2026-10-05 01:36:05 +08:00
catalog_limit : int = Field ( default = 10000 , ge = 1 , le = 100000 )
2026-10-05 08:18:56 +08:00
synology_previews : bool = False
2026-10-05 01:36:05 +08:00
organized_only : bool = False
folder_caption : bool = False
2026-10-05 10:38:20 +08:00
orientation : Literal [ "auto" , "landscape" , "portrait" ] | None = None
2026-10-18 14:59:35 +08:00
minimum_short_edge : int = Field ( default = 0 , ge = 0 , le = 50000 )
minimum_long_edge : int = Field ( default = 0 , ge = 0 , le = 50000 )
2026-10-08 23:18:11 +08:00
minimum_sharpness : float = Field ( default = 0 , ge = 0 , le = 10000 )
minimum_brightness : float = Field ( default = 0 , ge = 0 , le = 255 )
maximum_brightness : float = Field ( default = 255 , ge = 0 , le = 255 )
avoid_duplicates : bool = False
people_space : Literal [ "shared" , "personal" ] = "shared"
2026-10-05 08:43:07 +08:00
people_filter : bool = False
person_ids : list [ int ] = Field ( default_factory = list , max_length = 50 )
person_match : Literal [ "any" , "all" ] = "any"
2026-10-05 00:52:39 +08:00
credential_env : str | None = Field (
default = None , pattern = r "^FRAME_FLOW_SOURCE_[A-Z0-9_]+$"
)
class PhotoSourceView ( PhotoSourceInput ):
id : str
class PhotoItem ( StrictModel ):
2026-10-20 18:50:43 +08:00
starred : bool = False
excluded : bool = False
2026-10-05 00:52:39 +08:00
id : str
name : str
2026-10-05 01:36:05 +08:00
caption : dict [ str , str ] | None = None
2026-10-18 15:30:38 +08:00
source_path : str | None = None
2026-10-05 08:18:56 +08:00
provider_preview : bool = False
2026-10-05 10:38:20 +08:00
orientation : Literal [ "landscape" , "portrait" , "square" ] | None = None
width : int | None = None
height : int | None = None
2026-10-05 00:52:39 +08:00
class PhotoCatalog ( StrictModel ):
items : list [ PhotoItem ]
total : int
offset : int
limit : int
catalog_limit : int
class PhotoSources :
extensions = { ".jpg" , ".jpeg" , ".png" , ".webp" }
def __init__ ( self , db , settings , assets ):
self . db , self . settings , self . assets = db , settings , assets
self . cache = {}
2026-10-16 22:31:13 +08:00
self . api_items = {}
2026-10-23 19:59:02 +08:00
from pigway_frame_flow.providers.source_browser import SourceBrowser
self . browser = SourceBrowser ( self )
2026-10-05 08:43:07 +08:00
self . synology = SynologyPhotos ( settings )
2026-10-05 00:52:39 +08:00
# Register provider secrets before request IDs or errors can be reflected.
for row in self . list ():
if row [ "kind" ] == "webdav" and row . get ( "credential_env" ):
try :
self . headers (
PhotoSourceInput . model_validate (
{ k : v for k , v in row . items () if k != "id" }
)
)
except FlowError :
pass
def list ( self ):
return [
dict ( json . loads ( row [ "payload" ]), id = row [ "key" ][ 13 :])
for row in self . db . all (
"SELECT key,payload FROM app_config WHERE key LIKE 'photo_source:%' ORDER BY key"
)
]
def get ( self , source_id ):
row = self . db . one (
"SELECT payload FROM app_config WHERE key=?" , ( "photo_source:" + source_id ,)
)
if not row :
raise FlowError ( E . NOT_FOUND , "Photo source not found" , 404 )
return PhotoSourceInput . model_validate_json ( row [ "payload" ])
2026-10-17 01:39:23 +08:00
@staticmethod
def require_api_source ( source ):
# Block persisted legacy sources as well as new requests, before any disk read.
if source . kind == "local" :
raise FlowError (
E . FORBIDDEN ,
"Filesystem photo sources are disabled; use the Synology Photos API" ,
403 ,
)
2026-10-05 00:52:39 +08:00
def save ( self , source_id , source ):
2026-10-17 01:39:23 +08:00
self . require_api_source ( source )
2026-10-24 00:38:00 +08:00
if source . kind == "uploads" and (
source_id != "manual-uploads" or source . location != "."
):
raise FlowError ( E . INVALID_REQUEST , "Invalid manual upload source" )
if source_id == "manual-uploads" and source . kind != "uploads" :
raise FlowError ( E . CONFLICT , "Reserved manual upload source" )
2026-10-08 23:18:11 +08:00
if source . minimum_brightness > source . maximum_brightness :
raise FlowError ( E . INVALID_REQUEST , "Brightness range is reversed" )
2026-10-16 22:31:13 +08:00
if source . kind not in ( "local" , "synology-photos" ) and (
2026-10-08 23:18:11 +08:00
source . organized_only or source . folder_caption or source . synology_previews
):
raise FlowError (
E . UNSUPPORTED_CAPABILITY ,
"Folder captions and Synology previews require a local photo source" ,
)
2026-10-16 22:31:13 +08:00
if source . orientation and source . kind not in ( "local" , "synology-photos" ):
2026-10-05 10:38:20 +08:00
raise FlowError (
E . UNSUPPORTED_CAPABILITY ,
"Orientation filtering requires a local photo source" ,
)
2026-10-16 22:31:13 +08:00
if source . people_filter and source . kind not in ( "local" , "synology-photos" ):
2026-10-05 08:43:07 +08:00
raise FlowError (
E . INVALID_REQUEST ,
"Person filtering requires a local Synology photo source" ,
)
if any ( i <= 0 for i in source . person_ids ) or len ( set ( source . person_ids )) != len (
source . person_ids
):
raise FlowError (
E . INVALID_REQUEST , "Person IDs must be unique positive integers"
)
if source . people_filter and not source . person_ids :
raise FlowError ( E . INVALID_REQUEST , "Select at least one person" )
2026-10-05 00:52:39 +08:00
if source . kind == "local" :
self . local_directory ( source . location )
if source . credential_env :
raise FlowError (
E . INVALID_REQUEST , "Local sources do not use credentials"
)
2026-10-16 22:31:13 +08:00
elif source . kind == "synology-photos" :
location = source . location . strip ( "/" )
if location == "." :
location = ""
if " \\ " in location or any ( p in ( "." , ".." ) for p in location . split ( "/" )):
raise FlowError (
E . INVALID_REQUEST ,
"Photos API folder must be a relative folder path or root" ,
)
if source . credential_env or source . synology_previews :
raise FlowError (
E . INVALID_REQUEST ,
"Photos API sources use the shared Photos session and XL thumbnail download" ,
)
source = source . model_copy ( update = { "location" : location or "." })
2026-10-08 23:18:11 +08:00
elif source . kind == "webdav" :
2026-10-05 00:52:39 +08:00
source = source . model_copy (
update = {
"recursive" : False ,
"location" : source . location . rstrip ( "/" ) + "/" ,
}
)
parsed = urlsplit ( source . location )
if (
parsed . scheme != "https"
or not parsed . hostname
or parsed . username
or parsed . password
or parsed . query
or parsed . fragment
or parsed . port not in ( None , 443 )
):
raise FlowError (
E . INVALID_REQUEST , "WebDAV requires a plain HTTPS directory URL"
)
if parsed . hostname . lower () not in self . settings . image_url_hosts :
raise FlowError (
E . FORBIDDEN , "Configure the allowed image host first" , 403
)
self . db . execute (
"INSERT OR REPLACE INTO app_config(key,payload) VALUES(?,?)" ,
( "photo_source:" + source_id , encode ( source . model_dump ())),
)
self . cache . pop ( source_id , None )
2026-10-16 22:31:13 +08:00
self . api_items . pop ( source_id , None )
2026-10-05 00:52:39 +08:00
return dict ( source . model_dump (), id = source_id )
def delete ( self , source_id ):
self . get ( source_id )
for row in self . db . all ( "SELECT payload FROM jobs" ):
if json . loads ( row [ "payload" ])[ "source" ] . get ( "source_id" ) == source_id :
raise FlowError ( E . CONFLICT , "Remove jobs using this source first" , 409 )
for row in self . db . all (
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')"
):
payload = json . loads ( row [ "payload" ])
if payload . get ( "content" , payload ) . get ( "source_id" ) == source_id :
raise FlowError ( E . CONFLICT , "This source has pending operations" , 409 )
self . db . execute (
"DELETE FROM app_config WHERE key=?" , ( "photo_source:" + source_id ,)
)
2026-10-20 18:50:43 +08:00
self . db . execute ( "DELETE FROM photo_preferences WHERE source_id=?" , ( source_id ,))
2026-10-05 00:52:39 +08:00
self . cache . pop ( source_id , None )
2026-10-16 22:31:13 +08:00
self . api_items . pop ( source_id , None )
2026-10-05 00:52:39 +08:00
def local_directory ( self , location ):
root = self . settings . photo_root . resolve ()
directory = ( root / location ) . resolve ()
if Path ( location ) . is_absolute () or not directory . is_relative_to ( root ):
raise FlowError (
E . FORBIDDEN ,
"Photo directory must be inside the mounted photo root" ,
403 ,
)
if not directory . is_dir ():
raise FlowError (
E . NOT_FOUND ,
"Photo directory is unavailable; check its read-only mount" ,
404 ,
)
2026-10-05 07:59:48 +08:00
try :
with os . scandir ( directory ):
pass
except PermissionError :
raise FlowError (
E . FORBIDDEN ,
"Photo directory is not readable; configure the container's existing NAS read group" ,
403 ,
) from None
2026-10-05 00:52:39 +08:00
return directory
def headers ( self , source ):
if not source . credential_env :
return {}
try :
value = secret ( source . credential_env )
except OSError :
raise FlowError (
E . CONFIG_REQUIRED , "WebDAV credential secret is unavailable"
) from None
if value and value not in self . settings . additional_secrets :
self . settings . additional_secrets . append ( value )
try :
credentials = json . loads ( value )
username , password = credentials [ "username" ], credentials [ "password" ]
if (
not isinstance ( username , str )
or not isinstance ( password , str )
or ":" in username
):
raise ValueError ()
except ( ValueError , KeyError , TypeError ):
raise FlowError (
E . CONFIG_REQUIRED ,
"WebDAV credential secret must contain username and password" ,
) from None
for sensitive in ( value , username , password ):
if sensitive and sensitive not in self . settings . additional_secrets :
self . settings . additional_secrets . append ( sensitive )
encoded = base64 . b64encode (( username + ":" + password ) . encode ()) . decode ()
if encoded not in self . settings . additional_secrets :
self . settings . additional_secrets . append ( encoded )
return { "Authorization" : "Basic " + encoded }
2026-10-05 08:18:56 +08:00
def thumbnail ( self , path ):
candidate = path . parent / "@eaDir" / path . name / "SYNOPHOTO_THUMB_XL.jpg"
try :
if candidate . is_symlink () or not candidate . resolve () . is_relative_to (
path . parent . resolve ()
):
return None
if (
candidate . is_file ()
and candidate . stat () . st_size <= self . settings . max_bytes
):
return candidate
except OSError :
pass
return None
2026-10-05 22:44:41 +08:00
def catalog ( self , source_id , refresh = False , * , target_size = ( 1200 , 1600 )):
2026-10-05 00:52:39 +08:00
source = self . get ( source_id )
2026-10-17 01:39:23 +08:00
self . require_api_source ( source )
2026-10-05 00:52:39 +08:00
if not source . enabled :
raise FlowError ( E . CONFLICT , "Photo source is disabled" , 409 )
2026-10-24 00:38:00 +08:00
if source . kind == "uploads" :
return [
{
"id" : row [ "item_id" ],
"name" : row [ "name" ],
"caption" : json . loads ( row [ "caption" ]),
"starred" : bool ( row [ "starred" ]),
"excluded" : bool ( row [ "excluded" ]),
}
for row in self . db . all (
"SELECT s.item_id,s.caption,a.name,COALESCE(p.starred,0) AS starred,COALESCE(p.excluded,0) AS excluded FROM source_images s JOIN assets a ON a.id=s.id LEFT JOIN photo_preferences p ON p.source_id=s.source_id AND p.item_id=s.item_id WHERE s.source_id=? ORDER BY s.created_at DESC,s.id DESC" ,
( source_id ,),
)
]
2026-10-16 22:31:13 +08:00
session_identity = (
self . synology . sid () if source . kind == "synology-photos" else None
)
2026-10-05 22:44:41 +08:00
wanted = (
orientation ( * target_size )
if source . orientation == "auto"
else source . orientation
)
2026-10-05 00:52:39 +08:00
cached = self . cache . get ( source_id )
2026-10-05 22:44:41 +08:00
if (
cached
and not refresh
and time . monotonic () - cached [ 0 ] < 60
and cached [ 2 ] == wanted
2026-10-16 22:31:13 +08:00
and cached [ 3 ] == session_identity
2026-10-05 22:44:41 +08:00
):
2026-10-05 00:52:39 +08:00
return cached [ 1 ]
2026-10-05 08:43:07 +08:00
allowed = (
2026-10-08 23:18:11 +08:00
self . synology . paths (
source . person_ids ,
source . person_match ,
refresh = refresh ,
space = source . people_space ,
)
2026-10-16 22:31:13 +08:00
if source . people_filter and source . kind == "local"
2026-10-05 08:43:07 +08:00
else None
)
2026-10-05 00:52:39 +08:00
items = []
2026-10-05 10:38:20 +08:00
sizes = (
{ r [ "path" ]: r for r in self . db . all ( "SELECT * FROM photo_dimensions" )}
if source . orientation
else {}
)
size_updates = []
2026-10-05 00:52:39 +08:00
if source . kind == "local" :
directory = self . local_directory ( source . location )
root = self . settings . photo_root . resolve ()
# Do not follow symlink directories or import Synology's thumbnail trees.
for folder , dirs , files in os . walk ( directory , followlinks = False ):
dirs [:] = (
sorted (
d
for d in dirs
2026-10-05 01:36:05 +08:00
if d != "#recycle"
and not d . startswith ( "." )
2026-10-05 00:52:39 +08:00
and not d . startswith ( "@" )
and not Path ( folder , d ) . is_symlink ()
)
if source . recursive
else []
)
2026-10-05 01:36:05 +08:00
caption = path_caption (
Path ( folder ) . relative_to ( root ) . as_posix () + "/__photo__.jpg"
)
if source . organized_only and caption is None :
continue
2026-10-05 00:52:39 +08:00
for name in sorted ( files ):
path = Path ( folder , name )
if (
name . startswith ( "." )
or path . suffix . lower () not in self . extensions
or path . is_symlink ()
):
continue
try :
2026-10-05 08:18:56 +08:00
large = path . stat () . st_size > self . settings . max_bytes
preview = (
large
and source . synology_previews
and self . thumbnail ( path ) is not None
)
if large and not preview :
2026-10-05 00:52:39 +08:00
continue
relative = path . relative_to ( root ) . as_posix ()
2026-10-05 08:43:07 +08:00
if allowed is not None and relative not in allowed :
continue
2026-10-05 10:38:20 +08:00
info = {}
2026-10-06 08:47:03 +08:00
if wanted or source . kind == "local" :
2026-10-05 10:38:20 +08:00
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 )
2026-10-06 08:47:03 +08:00
if size is None and preview :
preview_path = self . thumbnail ( path )
size = (
dimensions ( root , preview_path )
if preview_path
else None
)
2026-10-05 10:38:20 +08:00
if size is None :
2026-10-06 08:47:03 +08:00
if wanted :
continue
size = ( 0 , 0 )
2026-10-05 10:38:20 +08:00
width , height = size
size_updates . append (
(
key ,
stamp . st_mtime_ns ,
stamp . st_size ,
width ,
height ,
)
)
2026-10-18 14:59:35 +08:00
if not self . resolution_matches ( source , width , height ):
continue
2026-10-05 10:38:20 +08:00
direction = orientation ( width , height )
2026-10-06 08:47:03 +08:00
if wanted and direction != wanted :
2026-10-05 10:38:20 +08:00
continue
info = {
"width" : width ,
"height" : height ,
"orientation" : direction ,
}
2026-10-06 08:47:03 +08:00
if width * height > MAX_IMAGE_PIXELS :
if (
source . synology_previews
and self . thumbnail ( path ) is not None
):
preview = True
else :
continue
2026-10-05 01:36:05 +08:00
items . append (
{
2026-10-05 10:38:20 +08:00
** info ,
2026-10-05 01:36:05 +08:00
"id" : relative ,
"name" : name ,
2026-10-05 08:18:56 +08:00
"provider_preview" : preview ,
2026-10-05 01:36:05 +08:00
"caption" : caption if source . folder_caption else None ,
}
)
2026-10-05 00:52:39 +08:00
except OSError :
continue
2026-10-05 01:36:05 +08:00
if len ( items ) >= source . catalog_limit :
2026-10-05 00:52:39 +08:00
break
2026-10-05 01:36:05 +08:00
if len ( items ) >= source . catalog_limit :
2026-10-05 00:52:39 +08:00
break
2026-10-16 22:31:13 +08:00
elif source . kind == "synology-photos" :
self . synology . sid ()
rows = self . synology . items (
source . person_ids if source . people_filter else [],
source . person_match ,
space = source . people_space ,
limit = 100000 ,
)
location = "" if source . location == "." else source . location . strip ( "/" )
self . api_items [ source_id ] = {}
for row in rows :
folder = row . get ( "additional" , {}) . get ( "folder" )
if isinstance ( folder , dict ):
folder = folder . get ( "name" )
if not isinstance ( folder , str ) or not isinstance (
row . get ( "filename" ), str
):
raise FlowError (
E . CLOUD_UNAVAILABLE ,
"Photos API response lacks folder or filename" ,
502 ,
)
relative = folder . strip ( "/" ) + "/" + row [ "filename" ]
if location and not relative . startswith ( location + "/" ):
continue
if not source . recursive and folder . strip ( "/" ) != location :
continue
caption = path_caption ( relative )
if source . organized_only and caption is None :
continue
size = row . get ( "additional" , {}) . get ( "resolution" , {})
width , height = size . get ( "width" , 0 ), size . get ( "height" , 0 )
2026-10-18 17:59:02 +08:00
# Photos resolution is already oriented; EXIF must not swap it twice.
2026-10-18 14:59:35 +08:00
if not self . resolution_matches ( source , width , height ):
continue
2026-10-16 22:31:13 +08:00
direction = orientation ( width , height )
if wanted and ( not width or not height or direction != wanted ):
continue
items . append (
{
"id" : str ( row [ "id" ]),
"name" : row [ "filename" ],
"provider_preview" : True ,
2026-10-18 15:30:38 +08:00
"source_path" : (
source . nas_root_path . rstrip ( "/" )
+ "/"
+ relative . lstrip ( "/" )
if source . nas_root_path
else "/" + relative . lstrip ( "/" )
),
2026-10-16 22:31:13 +08:00
"caption" : caption if source . folder_caption else None ,
"width" : width ,
"height" : height ,
"orientation" : direction ,
}
)
self . api_items [ source_id ][ str ( row [ "id" ])] = row
if len ( items ) >= source . catalog_limit :
break
2026-10-08 23:18:11 +08:00
elif source . kind in ( "google-drive" , "alipan" , "baidu" ):
from pigway_frame_flow.providers.cloud_drives import CloudDrives
items = CloudDrives ( self ) . catalog ( source_id , source )
2026-10-05 00:52:39 +08:00
else :
data = self . assets . fetch_url (
source . location ,
method = "PROPFIND" ,
headers = { ** self . headers ( source ), "Depth" : "1" },
limit = 2 * 1024 * 1024 ,
accepted_status = ( 207 ,),
)
if (
b " \x00 " in data
or b "<!DOCTYPE" in data . upper ()
or b "<!ENTITY" in data . upper ()
):
raise FlowError ( E . INVALID_REQUEST , "Unsafe WebDAV XML" )
try :
document = ElementTree . fromstring ( data )
base = urlsplit ( source . location )
prefix = posixpath . normpath ( unquote ( base . path )) . rstrip ( "/" ) + "/"
seen = set ()
for response in document . findall ( " {DAV:} response" ):
href = response . findtext ( " {DAV:} href" )
if not href or response . find ( ".// {DAV:} collection" ) is not None :
continue
url = urljoin ( source . location , href )
parsed = urlsplit ( url )
if (
( parsed . scheme , parsed . netloc ) != ( base . scheme , base . netloc )
or not posixpath . normpath ( unquote ( parsed . path )) . startswith (
prefix
)
or " \\ " in unquote ( parsed . path )
or url in seen
or parsed . query
or parsed . fragment
):
continue
2026-10-05 01:36:05 +08:00
caption = path_caption ( unquote ( parsed . path ))
if source . organized_only and caption is None :
continue
2026-10-05 00:52:39 +08:00
if Path ( parsed . path ) . suffix . lower () in self . extensions :
seen . add ( url )
items . append (
2026-10-05 01:36:05 +08:00
{
"id" : url ,
"name" : unquote ( Path ( parsed . path ) . name ),
"caption" : caption if source . folder_caption else None ,
}
2026-10-05 00:52:39 +08:00
)
2026-10-05 01:36:05 +08:00
if len ( items ) >= source . catalog_limit :
2026-10-05 00:52:39 +08:00
break
except ElementTree . ParseError :
raise FlowError ( E . INVALID_REQUEST , "Invalid WebDAV catalog" ) from None
2026-10-05 10:38:20 +08:00
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 ,
)
2026-10-16 22:31:13 +08:00
self . cache [ source_id ] = ( time . monotonic (), items , wanted , session_identity )
2026-10-05 00:52:39 +08:00
return items
2026-10-18 14:59:35 +08:00
@staticmethod
def resolution_matches ( source , width , height ):
short , long = sorted (( width , height ))
return short >= source . minimum_short_edge and long >= source . minimum_long_edge
2026-10-05 22:44:41 +08:00
def read ( self , source_id , item_id = None , * , target_size = ( 1200 , 1600 )):
2026-10-05 00:52:39 +08:00
source = self . get ( source_id )
2026-10-23 19:59:02 +08:00
browsed = (
self . browser . cached ( source_id , item_id )
if item_id and source . kind == "synology-photos"
else None
)
items = (
[ browsed [ 1 ]]
if browsed
else self . catalog ( source_id , target_size = target_size )
)
2026-10-20 18:50:43 +08:00
if not item_id :
items = eligible ( self . db , source_id , items )
2026-10-05 00:52:39 +08:00
if not items :
raise FlowError (
2026-10-20 18:50:43 +08:00
E . NOT_FOUND , "Photo source contains no eligible images" , 404
2026-10-05 00:52:39 +08:00
)
item = (
next (( x for x in items if x [ "id" ] == item_id ), None )
if item_id
else random . SystemRandom () . choice ( items )
)
if item is None :
raise FlowError ( E . NOT_FOUND , "Photo is not in this source" , 404 )
2026-10-24 00:38:00 +08:00
if source . kind == "uploads" :
row = self . db . one (
"SELECT id,caption FROM source_images WHERE source_id=? AND item_id=?" ,
( source_id , item [ "id" ]),
)
if not row :
raise FlowError ( E . NOT_FOUND , "Uploaded source picture not found" , 404 )
return (
self . assets . path ( row [ "id" ]) . read_bytes (),
item [ "name" ],
json . loads ( row [ "caption" ]),
)
2026-10-05 00:52:39 +08:00
if source . kind == "local" :
root = self . settings . photo_root . resolve ()
path = root / item [ "id" ]
2026-10-05 08:18:56 +08:00
if item . get ( "provider_preview" ):
preview = self . thumbnail ( path )
if preview is None :
raise FlowError ( E . NOT_FOUND , "Synology preview is unavailable" , 404 )
path = preview
2026-10-05 00:52:39 +08:00
if path . is_symlink () or not path . resolve () . is_relative_to (
self . local_directory ( source . location )
):
raise FlowError ( E . FORBIDDEN , "Photo path is outside its source" , 403 )
try :
# Open every component without following links; a renamed parent
# must not turn a previously cataloged image into a secret-file read.
parent_fd = os . open ( root , os . O_RDONLY | os . O_DIRECTORY )
try :
components = path . relative_to ( root ) . parts
for component in components [: - 1 ]:
child_fd = os . open (
component ,
os . O_RDONLY | os . O_DIRECTORY | os . O_NOFOLLOW ,
dir_fd = parent_fd ,
)
os . close ( parent_fd )
parent_fd = child_fd
file_fd = os . open (
components [ - 1 ],
os . O_RDONLY | os . O_NOFOLLOW | os . O_NONBLOCK ,
dir_fd = parent_fd ,
)
with os . fdopen ( file_fd , "rb" ) as handle :
if not stat . S_ISREG ( os . fstat ( handle . fileno ()) . st_mode ):
raise FlowError (
E . FORBIDDEN , "Photo must be a regular file" , 403
)
data = handle . read ( self . settings . max_bytes + 1 )
finally :
os . close ( parent_fd )
except OSError :
raise FlowError ( E . NOT_FOUND , "Photo is unavailable" , 404 ) from None
2026-10-16 22:31:13 +08:00
elif source . kind == "synology-photos" :
data = self . synology . download (
2026-10-23 19:59:02 +08:00
browsed [ 2 ] if browsed else self . api_items [ source_id ][ item [ "id" ]],
space = source . people_space ,
2026-10-16 22:31:13 +08:00
)
2026-10-17 23:28:04 +08:00
if source . folder_caption :
caption = dict ( item . get ( "caption" ) or {})
try :
2026-10-18 00:12:28 +08:00
caption [ "people" ] = " & " . join (
2026-10-23 19:59:02 +08:00
[ p [ "name" ] for p in item . get ( "people" , []) if p [ "name" ]]
if browsed
else self . synology . item_people (
item [ "id" ], space = source . people_space
)
2026-10-17 23:28:04 +08:00
)
except FlowError :
# Optional labels must not prevent displaying a valid photo.
logging . getLogger ( __name__ ) . warning (
"Photos person labels unavailable; keeping photo caption"
)
2026-10-18 08:19:03 +08:00
item = {
** item ,
"caption" : { k : v for k , v in caption . items () if v } or None ,
}
2026-10-08 23:18:11 +08:00
elif source . kind in ( "google-drive" , "alipan" , "baidu" ):
from pigway_frame_flow.providers.cloud_drives import CloudDrives
data = CloudDrives ( self ) . read ( source_id , source , item )
2026-10-05 00:52:39 +08:00
else :
data = self . assets . fetch_url ( item [ "id" ], headers = self . headers ( source ))
if len ( data ) > self . settings . max_bytes :
raise FlowError ( E . INVALID_REQUEST , "Image exceeds size limit" )
2026-10-18 17:43:25 +08:00
if source . orientation or source . minimum_short_edge or source . minimum_long_edge :
2026-10-18 14:59:35 +08:00
from io import BytesIO
from PIL import Image
2026-10-24 00:38:00 +08:00
with Image . open ( BytesIO ( data ), formats = ( "JPEG" , "PNG" , "WEBP" )) as image :
2026-10-18 17:43:25 +08:00
width , height = image . size
if image . getexif () . get ( 274 , 1 ) in ( 5 , 6 , 7 , 8 ):
width , height = height , width
wanted = (
orientation ( * target_size )
if source . orientation == "auto"
else source . orientation
)
# Validate the fetched rendition before a crop can hide a mismatch.
if wanted and orientation ( width , height ) != wanted :
raise FlowError (
E . INVALID_REQUEST ,
"Photo direction does not match the source orientation" ,
)
if not self . resolution_matches ( source , width , height ):
2026-10-18 14:59:35 +08:00
raise FlowError (
E . INVALID_REQUEST ,
"Photo is below the configured minimum resolution" ,
)
2026-10-05 01:36:05 +08:00
return data , item [ "name" ], item . get ( "caption" )