Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OpenRoot Photo Ingest

Crash-safe photo import and deduplication for Synology DSM 7.

One job per import, run as phases, pausing once for your review:

source ──copy──> staging/job-N ──hash+compare──> quarantine/job-N (duplicates)
                                                 └─ REVIEW ─> finalize ─> library
                                                                          then staging removed

Duplicates are found by full content hash against a persistent index of the target library, so a photo you imported months ago blocks a re-import today. They are moved to quarantine, never deleted, and nothing enters your library until you confirm. Permanent deletion requires typing DELETE PERMANENTLY.

No Docker, no database server, no compiled dependencies. A Python process and a SQLite file.

Demo

▶ Watch the walkthrough — installing the package in DSM, running an import end to end, and reviewing quarantined duplicates.

(61 MB QuickTime. GitHub will not preview it inline — use the download button on that page, or clone the repo and open demo/demo.mov locally.)


⚠️ Read this first

100% vibe coded. No support. Use at your own risk.

Every line of this project was written by an AI (Claude). It was built by prompting, reading the output, and testing the result — not by an engineer who has each line in their head. It has not been security audited, professionally reviewed, or run at scale by anyone but its author.

There is no support of any kind. No warranty, no SLA, no promise that issues get answered, no promise that pull requests get merged, no promise that this repository is maintained tomorrow. It is published because it might be useful to someone, not because anyone is standing behind it.

It moves and deletes your photos. The design goes out of its way to be safe — nothing enters your library until you confirm, duplicates are quarantined rather than deleted, permanent deletion requires typing DELETE PERMANENTLY, and every phase writes a manifest — but design intent is not a guarantee.

If you run this, you accept that:

  • Your photos are your responsibility. Have a backup that this software cannot reach. Test on a copy first.
  • Bugs are expected, including ones the author has not thought of.
  • You are the maintainer the moment you depend on it. Fork it.

If that isn't acceptable for your data, use Synology Photos or another supported, commercially backed tool instead. That is a completely reasonable choice and no offence will be taken.


Tested hardware

This has only ever been tested on a Synology DS920+ running DSM 7.2.

Everything else in this README — other models, ARM CPUs, DSM 7.0/7.1 — is expected to work because the package is architecture-independent and targets only documented DSM interfaces. None of it has been verified on real hardware. If you run it elsewhere, please open an issue reporting the model, DSM version, and whether it installed and started — successes are as useful as failures.

Treat first use on any other model as untested software touching your photo library. Point it at a copy first.


Synology model and DSM compatibility

There is one build artefact for every model. The package is declared arch="noarch" and contains no compiled code, so there is no per-model build and no Synology cross-compilation toolchain involved.

That works because of two deliberate constraints:

  1. Every dependency is pure Python. requirements.txt is starlette, uvicorn, and jinja2 — nothing else. FastAPI/pydantic (pydantic_core is Rust) and uvicorn[standard] (uvloop, httptools, websockets, watchfiles are all C) are deliberately excluded. build-spk.sh deletes MarkupSafe's optional C accelerator, which falls back to a pure-Python implementation, then fails the build if any .so, .dylib, or .pyd survives in the payload.
  2. The runtime is DSM's own interpreter, /usr/bin/python3. Nothing is compiled or bundled per-CPU.
Requirement Value Notes
DSM version 7.0-40000 or newer (os_min_ver) Developed and tested on 7.2 only
CPU architecture any — x86-64, ARMv8, ARMv7 noarch package, pure-Python payload
Python on the NAS 3.8+, already present in DSM 7 DSM 7.2 ships 3.8.15 at /usr/bin/python3
Disk a local volume for SPM_DATA not a network mount — see Storage

DSM 6 is not supported. The package scripts, the privilege model, and authenticate.cgi behaviour all assume DSM 7.

Building for a NAS with a different Python

The one thing that genuinely varies by model and DSM release is the bundled Python version. The build resolves dependencies for the target interpreter, not for your build machine — override TARGET_PY if your NAS differs:

ssh admin@your-nas /usr/bin/python3 --version   # find out what you're targeting
TARGET_PY=3.9 ./build-spk.sh

Getting this wrong produces a package that imports perfectly on your laptop and dies on the NAS. The build defends against it in two places: it runs tools/check_requires_python.py over the vendored packages to reject any wheel whose Requires-Python excludes the target, and it import-checks the payload under a real interpreter of the target version if one is available (uv python install 3.8 provides one). If it can't find one it warns loudly rather than claiming success.

Build options

Variable Default Effect
VERSION 1.0.0-0001 version string in INFO and the output filename
TARGET_PY 3.8 Python version dependencies are resolved for
PYTHON python3 interpreter used to run the build
WITH_UI 1 DSM Main Menu tile (ui/, dsmuidir)
WITH_PORTCONF 0 firewall port declaration (conf/resource, conf/*.sc)
MINIMAL 0 1 turns both extras off — the fallback if an install fails

WITH_PORTCONF defaults to off: on DSM 7.2 its presence made Package Center fail the install with broken_by: install_corruption, bisected against an otherwise identical build that installs cleanly. The firewall entry is a convenience; you can add the port rule by hand. If Package Center reports a bare "Failed to install" on your model, MINIMAL=1 ./build-spk.sh is the first thing to try — it disables both DSM-parsed extras and leaves only the service.


Install

A. As a DSM package (the real deployment)

⬇ Download the latest .spk from Releases — one noarch artefact, no build required. The same file is also committed in dist/ if you prefer to browse it in the repo.

Or build it yourself from source:

./build-spk.sh      # -> dist/OpenRootPhotoIngest-1.0.0-0001-noarch.spk

Either way: DSM → Package Center → Manual Install → select the .spk. DSM will warn that the publisher is unknown; that is expected for any package not distributed through Synology's own channel.

Config lives at /var/packages/OpenRootPhotoIngest/var/spm.env.

The package runs as the unprivileged user OpenRootPhotoIngest, so after installing you must grant it access to each shared folder it will touch: Control Panel → Shared Folder → Edit → Permissions → System internal user. The app's Settings → Diagnostics page shows exactly what it can and cannot reach, which is the fastest way to find a missing permission.

Full packaging detail, including the DSM script lifecycle and troubleshooting, is in PACKAGING.md.

B. Straight from a checkout on the NAS

Useful for trying it out before committing to a package. DSM 7 already ships a sufficient Python, so over SSH:

sudo mkdir -p /volume1/apps/spm && cd /volume1/apps/spm
sudo git clone <this repo> . && sudo /usr/bin/python3 -m venv .venv
sudo .venv/bin/pip install -r requirements.txt
sudo SPM_DATA=/volume1/apps/spm/data \
     SPM_ALLOWED_ROOTS=/volume1:/volumeUSB1 \
     .venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8777

To survive reboots without packaging anything: Control Panel → Task Scheduler → Triggered Task → Boot-up, user root, running /volume1/apps/spm/run.sh (that script is just the command above).

C. On a dev machine (any Linux/macOS, Python 3.8+)

python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
export SPM_DATA=./data
export SPM_ALLOWED_ROOTS="$PWD/sandbox"      # scope the blast radius while testing
export SPM_AUTH_MODE=none SPM_HOST=127.0.0.1 # no DSM here to authenticate against
mkdir -p sandbox/{usb,staging,quarantine,library}
python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8777

Open http://localhost:8777. SPM_AUTH_MODE=none disables authentication entirely; the app refuses to start in that mode unless bound to loopback.


Authentication — a reverse proxy is mandatory

The app has no login of its own. It validates your existing DSM session via authenticate.cgi, then requires you to be a DSM administrator (or to be on the allowed_users allowlist in Settings).

That only works if the app is served from the same hostname as DSM, because the browser will not send DSM's session cookie anywhere else:

Control Panel → Login Portal → Advanced → Reverse Proxy · Source: https://<the same hostname you use for DSM>:8778 · Destination: http://localhost:8777

Then bind to 127.0.0.1 so port 8777 is not reachable directly.

Full detail, including why file permissions are not a substitute for the admin check, is in AUTH.md.


Configuration

Env var Default Meaning
SPM_DATA /var/lib/spm SQLite DB, manifests, logs
SPM_ALLOWED_ROOTS /volume1:/volume2:/volumeUSB1:/volumeUSB2 colon-separated. Any path outside these is rejected at the API boundary.
SPM_PORT 8777 listen port
SPM_HOST 0.0.0.0 bind address — set to 127.0.0.1 when behind the reverse proxy
SPM_AUTH_MODE dsm dsm validates the DSM session; none disables auth and is loopback-only
SPM_AUTH_CGI /usr/syno/synoman/webman/modules/authenticate.cgi DSM's session validator

Adjust SPM_ALLOWED_ROOTS for your own volume layout — a NAS with a single volume and no USB expansion only needs /volume1.

Everything else (staging/quarantine/library roots, hash algorithm, organise rule, index freshness, retention) lives in the Settings page and is stored in SQLite.


Storage

SQLite, one file at $SPM_DATA/spm.sqlite3, in WAL mode. No database server to install, depend on, or cross-compile a driver for.

  • Working state: jobs, job_events, job_metrics, intents, hash_cache, target_index, job_staging_index, dup_matches, quarantine_items.
  • Durable record: JSON manifests in $SPM_DATA/manifests/, written at each phase boundary. These are the recovery artefact if the database is ever lost.

Sizing: ~1M files (≈5 TB of JPEGs) lands around 300–500 MB including indexes. SQLite is comfortable into the tens of millions of rows.

SPM_DATA must be on a local volume. SQLite's file locking is unsafe over NFS/SMB — pointing it at a network mount causes corruption, not an error.

Known gap: retention is not implemented. hash_cache and old files rows grow without bound across scans. Add a retention sweep before running this continuously for months.


Tests

for t in tests/test_*.py; do .venv/bin/python "$t" || break; done
Suite Covers
test_pipeline.py full import: copy → index → dedup → quarantine → review → finalize → cleanup
test_jobs.py park-and-continue scheduling, pause/resume, delete-job, deleted jobs not shadowing future imports
test_review.py skip vs suffix outcomes, /api/preview refusing anything not in dup_matches
test_http.py routes, validation, CSRF, data-location relocation
test_permissions.py an unreadable folder fails the import instead of looking successful
test_auth.py DSM single sign-on, admin/allowlist authorization, CSRF
test_upgrade.py upgrading a pre-v2 database in place without losing data
test_charts.py runs the chart JavaScript under node (skips if node absent)

They are plain scripts with no test-runner dependency, and they run on the NAS's Python 3.8 as well as on a modern one. If you are adding a dependency or changing packaging, run them under 3.8 specifically — two real bugs (typing_extensions needing 3.9 internals, and list[dict] annotations) passed cleanly under 3.9 and failed on the NAS.

Documentation

  • PACKAGING.md — how the .spk is built, the DSM script lifecycle, and what to check when an install fails.
  • AUTH.md — the authenticate.cgi contract and the threat model.
  • ARCHITECTURE.md — the engineering view of the pipeline.
  • The How it works page in the app covers the five stages, how a duplicate is decided, why the first import into a library is slower, and what guarantees nothing is lost.

Contributing

To be clear about expectations: issues and pull requests may go unanswered indefinitely. Forking is a first-class option here, not a last resort.

That said, reports from models other than the DS920+ are the most useful thing anyone can contribute — see Tested hardware. Please include the model, DSM version, /usr/bin/python3 --version, and the relevant output from Settings → Diagnostics.

Keep requirements.txt pure Python. Adding a package with a compiled extension breaks noarch and turns one artefact into a per-architecture build matrix; build-spk.sh will fail the build rather than let it happen silently.

License

MIT — see LICENSE. Note the warranty disclaimer; it is not boilerplate here, it is the point.

References

About

Crash-safe photo import and deduplication for Synology DSM 7. AI-written, unsupported, tested only on DS920+.

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages