Add mount-free Synology Photos API sources with bounded image cache
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""Offline, explicit local-to-Photos-API source conversion; defaults to dry run."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||
|
||||
|
||||
def convert(database, source_ids, *, apply=False):
|
||||
connection = sqlite3.connect(f"file:{database}?mode=rw", uri=True)
|
||||
try:
|
||||
updates = []
|
||||
for source_id in source_ids:
|
||||
key = "photo_source:" + source_id
|
||||
row = connection.execute(
|
||||
"SELECT payload FROM app_config WHERE key=?", (key,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("Source not found: " + source_id)
|
||||
source = PhotoSourceInput.model_validate_json(row[0])
|
||||
if source.kind != "local":
|
||||
raise ValueError(
|
||||
"Only explicitly selected local sources can be converted"
|
||||
)
|
||||
location = source.location.strip("/")
|
||||
if (
|
||||
source.location.startswith("/")
|
||||
or "\\" in location
|
||||
or ".." in location.split("/")
|
||||
):
|
||||
raise ValueError(
|
||||
"Review the source's relative Photos folder path first"
|
||||
)
|
||||
payload = source.model_copy(
|
||||
update={"kind": "synology-photos", "synology_previews": False}
|
||||
)
|
||||
updates.append((payload.model_dump_json(), key))
|
||||
backup = None
|
||||
if apply:
|
||||
backup = database.with_name(
|
||||
database.name
|
||||
+ ".before-photos-api-"
|
||||
+ datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
|
||||
)
|
||||
with sqlite3.connect(backup) as snapshot:
|
||||
connection.backup(snapshot)
|
||||
backup.chmod(0o600)
|
||||
with connection:
|
||||
connection.executemany(
|
||||
"UPDATE app_config SET payload=? WHERE key=?", updates
|
||||
)
|
||||
return {
|
||||
"applied": apply,
|
||||
"sources": source_ids,
|
||||
"backup": str(backup) if backup else None,
|
||||
}
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--database", type=Path, required=True)
|
||||
parser.add_argument("--source", action="append", required=True)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument(
|
||||
"--service-stopped",
|
||||
action="store_true",
|
||||
help="Confirm the target service is stopped before changing its database",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.apply and not args.service_stopped:
|
||||
parser.error(
|
||||
"Stop the target service and pass --service-stopped before applying"
|
||||
)
|
||||
print(json.dumps(convert(args.database, args.source, apply=args.apply), indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Isolated Mock UI only: explicit API type, people spaces, payload and no mount wording. */
|
||||
const assert=require('assert'),{chromium}=require('playwright');
|
||||
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let checks=0;
|
||||
try{for(const width of [1440,390])for(const lang of ['zh','en'])for(const theme of ['light','dark']){const p=await b.newPage({viewport:{width,height:1000}}),errors=[];let saved;const spaces=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/photo-sources',r=>r.fulfill({json:[]}));await p.route('**/api/v1/photo-sources/api-fixture',r=>{saved=r.request().postDataJSON();return r.fulfill({json:{...saved,id:'api-fixture'}})});await p.route('**/api/v1/integrations/synology-photos',r=>r.fulfill({json:{configured:true,connected:true}}));await p.route('**/api/v1/integrations/synology-photos/people?*',r=>{spaces.push(new URL(r.request().url()).searchParams.get('space'));return r.fulfill({json:[{id:7,name:'Fixture person',count:3,named:true}]})});await p.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await p.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await p.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await p.locator('#login-form button').click();await p.locator('.hero').waitFor();assert(await p.evaluate(()=>state.settings.mock));await p.evaluate(async({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;await navigate('photo-sources')},{lang,theme});await p.locator('#add-photo-source').click();await p.locator('#source-kind').selectOption('synology-photos');await p.locator('#source-id').fill('api-fixture');await p.locator('#source-name').fill('Photos API');assert(await p.locator('#source-credential').isHidden());assert(await p.locator('#source-oauth').isHidden());assert(await p.locator('#source-synology-previews').isHidden());assert(await p.locator('#source-orientation').isEnabled());await p.locator('#source-people-filter').check();await p.locator('#source-people-load').click();await p.locator('[data-person="7"]').check();await p.locator('#source-people-space').selectOption('personal');await p.locator('#source-people-load').click();assert(!await p.locator('[data-person="7"]').isChecked());await p.locator('[data-person="7"]').check();await p.locator('#source-person-match').selectOption('all');assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));if(process.env.FRAME_FLOW_SCREENSHOT_DIR&&lang==='zh'&&theme==='dark'){require('fs').mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/photos-api-'+width+'.png'});}await p.locator('#photo-source-form button[type="submit"]').click();await p.waitForFunction(()=>!document.querySelector('#modal').open);assert.equal(saved.kind,'synology-photos');assert.equal(saved.people_space,'personal');assert.equal(saved.person_match,'all');assert.deepEqual(saved.person_ids,[7]);assert.equal(saved.credential_env,null);assert.equal(saved.synology_previews,false);assert.deepEqual(spaces,['shared','personal']);assert.deepEqual(errors,[]);await p.close();checks++;}console.log('Photos API UI:',checks,'profiles PASS; type/space/person any-all/config and responsive layout verified');}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Smoke-check an already running isolated Mock container, via its loopback HTTP."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
assert os.getuid() == 10333 and os.getgid() == 10333
|
||||
assert not Path("/photos").exists()
|
||||
for _ in range(30):
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
"http://127.0.0.1:18787/ready", timeout=2
|
||||
) as response:
|
||||
assert json.load(response)["status"] == "ready"
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
else:
|
||||
raise SystemExit("Mock readiness failed")
|
||||
with urllib.request.urlopen(
|
||||
"http://127.0.0.1:18787/api/v1/version", timeout=2
|
||||
) as response:
|
||||
version = json.load(response)
|
||||
assert version["version"] == "0.1.9"
|
||||
assert Path("/data/frameflow.db").is_file()
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ready": True,
|
||||
"version": version["version"],
|
||||
"commit": version["commit"],
|
||||
"uid": os.getuid(),
|
||||
"gid": os.getgid(),
|
||||
"no_photos_mount": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Read-only live Photos check. Never creates an app, worker, job or frame driver."""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput, PhotoSources
|
||||
from pigway_frame_flow.settings import Settings
|
||||
from pigway_frame_flow.storage.database import Database
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--bootstrap", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
if Path("/photos").exists():
|
||||
raise SystemExit("Verification requires /photos to be absent")
|
||||
bootstrap = json.loads(args.bootstrap.read_text())
|
||||
os.environ["SYNOLOGY_PHOTOS_URL"] = bootstrap["url"]
|
||||
os.environ["SYNOLOGY_PHOTOS_CERT_SHA256"] = bootstrap["pin"]
|
||||
settings = Settings(mock=True, write_enabled=False, api_key="", api_token="")
|
||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
session = settings.config_dir / "synology-photos-session.json"
|
||||
session.write_text(json.dumps(bootstrap["session"]))
|
||||
session.chmod(0o600)
|
||||
db = Database(settings.data_dir / "verification.db")
|
||||
photos = PhotoSources(db, settings, None)
|
||||
report = {"no_photos_mount": True, "device_writes": 0, "spaces": {}}
|
||||
try:
|
||||
for space in ("shared", "personal"):
|
||||
people = photos.synology.people(space)
|
||||
candidates = sorted(
|
||||
(p for p in people if p["count"] > 1), key=lambda p: p["count"]
|
||||
)
|
||||
if len(candidates) < 2:
|
||||
raise RuntimeError("Need two accessible populated person albums")
|
||||
selected = [p["id"] for p in candidates[:2]]
|
||||
one = photos.synology.items(selected[:1], space=space)
|
||||
two = photos.synology.items(selected[1:], space=space)
|
||||
ids_one, ids_two = {r["id"] for r in one}, {r["id"] for r in two}
|
||||
any_rows = photos.synology.items(selected, "any", space=space)
|
||||
all_rows = photos.synology.items(selected, "all", space=space)
|
||||
assert {r["id"] for r in any_rows} == ids_one | ids_two
|
||||
assert {r["id"] for r in all_rows} == ids_one & ids_two
|
||||
source_id = "live-" + space
|
||||
photos.save(
|
||||
source_id,
|
||||
PhotoSourceInput(
|
||||
name="Read-only verification",
|
||||
kind="synology-photos",
|
||||
location=".",
|
||||
people_space=space,
|
||||
people_filter=True,
|
||||
person_ids=selected,
|
||||
),
|
||||
)
|
||||
catalog = photos.catalog(source_id)
|
||||
assert catalog
|
||||
data, _, _ = photos.read(source_id, catalog[0]["id"])
|
||||
with Image.open(io.BytesIO(data)) as image:
|
||||
image.load()
|
||||
decoded = {
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"format": image.format,
|
||||
}
|
||||
cache = photos.synology.image_cache
|
||||
before = cache.downloads
|
||||
assert photos.read(source_id, catalog[0]["id"])[0] == data
|
||||
assert cache.downloads == before
|
||||
report["spaces"][space] = {
|
||||
"people": len(people),
|
||||
"any_count": len(any_rows),
|
||||
"all_count": len(all_rows),
|
||||
"catalog_count": len(catalog),
|
||||
"bytes": len(data),
|
||||
"decoded": decoded,
|
||||
"cache_reused": True,
|
||||
}
|
||||
# A deliberately invalid session is isolated to this verification config;
|
||||
# no logout request or change is made to the original NAS session.
|
||||
photos.synology.path.write_text(
|
||||
json.dumps({"sid": "frameflow-deliberately-invalid-session"})
|
||||
)
|
||||
try:
|
||||
photos.synology.people()
|
||||
except FlowError as error:
|
||||
assert error.status == 409
|
||||
report["expired_session"] = "explicit_reconnect_required"
|
||||
else:
|
||||
raise RuntimeError("Invalid session unexpectedly accepted")
|
||||
report["cache_bytes"] = sum(
|
||||
p.stat().st_size
|
||||
for p in (settings.data_dir / "cache/synology-photos").glob("*.image")
|
||||
)
|
||||
assert report["cache_bytes"] <= settings.photos_cache_bytes
|
||||
report["original_download"] = "not_supported_by_this_verified_implementation"
|
||||
args.output.write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(json.dumps(report, indent=2))
|
||||
finally:
|
||||
session.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user