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.
▶ 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.)
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.
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.
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:
- Every dependency is pure Python.
requirements.txtisstarlette,uvicorn, andjinja2— nothing else. FastAPI/pydantic (pydantic_coreis Rust) anduvicorn[standard](uvloop, httptools, websockets, watchfiles are all C) are deliberately excluded.build-spk.shdeletes MarkupSafe's optional C accelerator, which falls back to a pure-Python implementation, then fails the build if any.so,.dylib, or.pydsurvives in the payload. - 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.
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.shGetting 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.
| 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.
⬇ 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.spkEither 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.
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 8777To 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).
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 8777Open http://localhost:8777. SPM_AUTH_MODE=none disables authentication
entirely; the app refuses to start in that mode unless bound to loopback.
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.
| 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.
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_DATAmust 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.
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.
PACKAGING.md— how the.spkis built, the DSM script lifecycle, and what to check when an install fails.AUTH.md— theauthenticate.cgicontract 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.
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.
MIT — see LICENSE. Note the warranty disclaimer; it is not
boilerplate here, it is the point.
- Synology 3rd-Party Developer Guide
- Application Authentication — the
authenticate.cgicontract this app relies on