Add mount-free Synology Photos API sources with bounded image cache
This commit is contained in:
@@ -119,12 +119,23 @@ class SynologyPhotos:
|
||||
result = json.loads(raw)
|
||||
if not result.get("success"):
|
||||
code = result.get("error", {}).get("code")
|
||||
if api == "SYNO.API.Auth" or code in (105, 106, 107, 119):
|
||||
if api == "SYNO.API.Auth" or code in (106, 107, 119):
|
||||
if api != "SYNO.API.Auth":
|
||||
self.disconnect()
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED, "Reconnect Synology Photos account", 409
|
||||
)
|
||||
if code in (102, 103, 104):
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"This NAS Photos API or method is unavailable",
|
||||
)
|
||||
if code == 105:
|
||||
raise FlowError(
|
||||
E.FORBIDDEN,
|
||||
"Photos account cannot access the selected space",
|
||||
403,
|
||||
)
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE, "Synology Photos query was rejected", 502
|
||||
)
|
||||
@@ -141,7 +152,7 @@ class SynologyPhotos:
|
||||
sid = json.loads(self.path.read_text())["sid"]
|
||||
if not isinstance(sid, str) or not sid:
|
||||
raise ValueError()
|
||||
except (OSError, ValueError, KeyError):
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED, "Connect Synology Photos account first", 409
|
||||
) from None
|
||||
@@ -269,3 +280,156 @@ class SynologyPhotos:
|
||||
)
|
||||
self.cache[key] = (time.monotonic(), selected)
|
||||
return selected
|
||||
|
||||
def items(self, person_ids=(), mode="any", *, space="shared", limit=10000):
|
||||
"""Intersect identities, not filenames, for any/all people selection."""
|
||||
prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto"
|
||||
if mode not in ("any", "all") or space not in ("shared", "personal"):
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid Photos selection")
|
||||
with self.lock:
|
||||
self.sid()
|
||||
if person_ids:
|
||||
known = {x["id"] for x in self.people(space)}
|
||||
if not set(person_ids) <= known:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Selected person no longer exists"
|
||||
)
|
||||
groups = []
|
||||
for person in person_ids or [None]:
|
||||
rows = self.pages(
|
||||
prefix + ".Browse.Item",
|
||||
7,
|
||||
additional=json.dumps(["folder", "thumbnail", "resolution"]),
|
||||
**({"person_id": person} if person is not None else {}),
|
||||
)
|
||||
groups.append(
|
||||
{str(r["id"]): r for r in rows if r.get("type") == "photo"}
|
||||
)
|
||||
selected = set(groups[0])
|
||||
for group in groups[1:]:
|
||||
selected = (
|
||||
selected & group.keys()
|
||||
if mode == "all"
|
||||
else selected | group.keys()
|
||||
)
|
||||
merged = {key: row for group in groups for key, row in group.items()}
|
||||
return [merged[key] for key in sorted(selected, key=int)][:limit]
|
||||
|
||||
def download(self, row, *, space="shared", rendition="thumbnail"):
|
||||
from pigway_frame_flow.providers.photos_cache import PhotosCache
|
||||
|
||||
sid = self.sid()
|
||||
url, pin = self.endpoint()
|
||||
prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto"
|
||||
if rendition == "thumbnail":
|
||||
thumb = row.get("additional", {}).get("thumbnail", {})
|
||||
if not thumb.get("cache_key") or not thumb.get("unit_id"):
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY, "Photos thumbnail is unavailable"
|
||||
)
|
||||
api, method = prefix + ".Thumbnail", "get"
|
||||
params = dict(
|
||||
id=thumb["unit_id"],
|
||||
cache_key=thumb["cache_key"],
|
||||
size="xl",
|
||||
type="unit",
|
||||
)
|
||||
else:
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Only verified XL thumbnails are supported; original download is unavailable",
|
||||
)
|
||||
if not hasattr(self, "image_cache"):
|
||||
self.image_cache = PhotosCache(self.settings)
|
||||
# Session/endpoint scoping prevents a different account from reusing a
|
||||
# previous account's cached photo. Cache keys never contain raw secrets.
|
||||
identity = hashlib.sha256((sid + str(url.geturl()) + pin).encode()).hexdigest()
|
||||
key = json.dumps(
|
||||
[
|
||||
identity,
|
||||
space,
|
||||
row["id"],
|
||||
rendition,
|
||||
row.get("indexed_time"),
|
||||
row.get("filesize"),
|
||||
row.get("additional", {}).get("thumbnail", {}).get("cache_key"),
|
||||
]
|
||||
)
|
||||
return self.image_cache.read(
|
||||
key,
|
||||
lambda handle, cap, deadline: self._download(
|
||||
api, method, params, sid, handle, cap, deadline
|
||||
),
|
||||
)
|
||||
|
||||
def _download(self, api, method, params, sid, handle, cap, deadline):
|
||||
if (api, method) not in {
|
||||
("SYNO.FotoTeam.Thumbnail", "get"),
|
||||
("SYNO.Foto.Thumbnail", "get"),
|
||||
}:
|
||||
raise FlowError(E.FORBIDDEN, "Unsupported Photos download", 403)
|
||||
url, pin = self.endpoint()
|
||||
connection = http.client.HTTPSConnection(
|
||||
url.hostname,
|
||||
url.port or 443,
|
||||
timeout=30,
|
||||
context=ssl._create_unverified_context(),
|
||||
)
|
||||
try:
|
||||
connection.connect()
|
||||
certificate = connection.sock.getpeercert(binary_form=True)
|
||||
if not certificate or hashlib.sha256(certificate).hexdigest() != pin:
|
||||
raise FlowError(E.FORBIDDEN, "Synology Photos certificate changed", 403)
|
||||
connection.request(
|
||||
"POST",
|
||||
"/webapi/entry.cgi",
|
||||
urlencode(dict(api=api, method=method, version=2, _sid=sid, **params)),
|
||||
{"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise ValueError()
|
||||
if "json" in (response.getheader("Content-Type") or "").lower():
|
||||
result = json.loads(response.read(65537))
|
||||
if result.get("error", {}).get("code") in (106, 107, 119):
|
||||
self.disconnect()
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED, "Reconnect Synology Photos account", 409
|
||||
)
|
||||
if result.get("error", {}).get("code") == 105:
|
||||
raise FlowError(
|
||||
E.FORBIDDEN, "Photos account cannot download this photo", 403
|
||||
)
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE, "Photos download was rejected", 502
|
||||
)
|
||||
expected = response.getheader("Content-Length")
|
||||
if expected and int(expected) > cap:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photos image exceeds download limit; select thumbnail",
|
||||
)
|
||||
total = 0
|
||||
while True:
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError()
|
||||
chunk = response.read(min(65536, cap - total + 1))
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > cap:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photos image exceeds download limit; select thumbnail",
|
||||
)
|
||||
handle.write(chunk)
|
||||
if expected and total != int(expected):
|
||||
raise ValueError()
|
||||
except (OSError, ValueError, http.client.HTTPException):
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE,
|
||||
"Photos download interrupted; retry explicitly",
|
||||
502,
|
||||
) from None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
Reference in New Issue
Block a user