Skip to content

feat(synology): add Synology Photos adapter for server-to-server migration#1330

Open
ywwzwb wants to merge 36 commits into
simulot:developfrom
ywwzwb:feature/synology-adapter-pr
Open

feat(synology): add Synology Photos adapter for server-to-server migration#1330
ywwzwb wants to merge 36 commits into
simulot:developfrom
ywwzwb:feature/synology-adapter-pr

Conversation

@ywwzwb

@ywwzwb ywwzwb commented Mar 23, 2026

Copy link
Copy Markdown

Summary

Add a new upload from-synology command that migrates photos from Synology Photos to Immich.

  • New Synology Photos adapter (adapters/synology/) with full API client for SYNO.Foto.* endpoints, supporting authentication with SynoToken
  • Albums, tags, people — imported with metadata preserved; face recognition data mapped to Person: <name> tags
  • Live photos — handled via ZIP download API (image + video bundled), with fallback for single-file responses
  • Filtering--albums, --tags, --people flags to limit scope of import
  • Timestamp correction — compensates for Synology's local-time-as-UTC storage
  • Bug fixes in Asset.Close() (nil pointer), errors.Join accumulation, and temp file cleanup
  • Unit tests for Asset.Close, album filter, and timestamp correction logic

Usage

immich-go upload from-synology \
  --server=http://immich:2283 --api-key=<key> \
  --synology-url=https://nas:5001 \
  --synology-user=admin --synology-pass=secret

Tested with

Component Version
Synology Photos 1.8.2-10090
Immich 2.5.6

Test plan

  • Unit tests pass (go test ./...)
  • Build check (go build ./...)
  • Login to Synology and browse all items
  • Filter by album / tag / people
  • Live photo upload (ZIP and single-file response paths)
  • Timestamps correct in Immich after upload

🤖 Generated with Claude Code

ywwzwb and others added 30 commits March 23, 2026 13:48
Add support for migrating photos from Synology Photos to Immich:
- New client for Synology Photos API (SYNO.Foto.* endpoints)
- Support importing photos, videos, albums, and tags
- Face recognition data converted to "Person: Name" tags
- GPS coordinates and descriptions preserved
- Filter by albums, tags, or people
- CLI command: upload from-synology

Includes:
- adapters/synology/client.go - API client with auth and retry
- adapters/synology/models.go - Data models for Synology API
- adapters/synology/synology.go - Adapter implementation
- adapters/synology/cmd.go - CLI command registration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add Synology Photos to the list of supported sources:
- Key Features section
- Quick Start example
- Upload commands overview
- Popular Use Cases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Update API version from 3 to 6 for better DSM compatibility
- Add session and syno_token parameters for proper auth
- Add detailed error messages for common error codes
- Include response body in parsing errors for debugging
- Add helpful tips for common authentication failure causes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Based on acme.sh implementation:
1. Query SYNO.API.Info first to get correct API path and version
2. Use GET request instead of POST for login
3. Add format=sid and enable_syno_token=yes parameters
4. Store and reuse actual api path from DSM discovery

This should fix error code 101 by using the correct API endpoint
that the specific DSM version supports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Previously when API returned success=false, the code would still
try to parse the response into the result struct and return nil.

Now properly check for success=false and return an error with
the error code and message before attempting to parse the result.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Simplify the doRequest retry logic to properly return errors
instead of tracking lastErr which could be nil.

- Return errors immediately on final retry instead of using lastErr
- Remove debug output clutter
- Better error messages for API failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The saveTags function uses tag.Value when calling UpsertTags,
but synology adapter was only setting Name field, leaving Value empty.
This caused 400 Bad Request errors when creating tags.

Now set both fields:
- Name: for display/logging
- Value: for API calls (required by UpsertTags)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Problem: Direct streaming from Synology caused "closed pipe" errors
because network reader was too slow for upload, or connection timed out.

Solution: Download files to local temp files before upload:
1. Open() creates a temp file
2. Downloads entire content to temp file
3. Seeks to beginning
4. Returns temp file handle
5. Close() removes temp file

This ensures:
- Stable upload without network timeout
- Proper fs.File interface implementation
- Automatic temp file cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Live photos in Synology have type="live" and contain both an image
and a video file with the same basename (e.g., IMG_9948.HEIC and IMG_9948.MOV).

Changes:
1. Add IsLivePhoto() and LivePhotoVideoFilename() methods to Item
2. Detect live photos and process both image and video parts
3. Create separate Assets for image and video with correct filenames
4. Use thumbnail's unit_id for video download when different from main item

This ensures Immich can properly recognize and merge live photos.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Problem: When user didn't specify --from-albums, but SYNO.Foto.Browse.Album
API returned albums, code only processed items within those albums.
Photos not belonging to any album were skipped.

Solution: Changed logic to:
- If --from-albums specified: process only those album items
- Otherwise: process ALL items via processAllItems (timeline API)

This ensures all photos are imported, not just those in albums.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Synology Photos provides a special API to download live photos as a ZIP
bundle containing both the HEIC image and MOV video files. This is more
reliable than downloading them separately.

Changes:
- Add DownloadLivePhotoZip() to download live photos as ZIP
- Add synologyLivePhotoFS to handle ZIP extraction
- Update processLivePhoto() to use shared ZIP bundle
- Files are extracted on first access and cleaned up after use

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The shared synologyLivePhotoFS was being cleaned up when the first
file (image) was closed, causing the second file (video) to fail with
"no such file or directory".

Fix: Each live photo asset now has its own FS instance, avoiding
lifecycle conflicts. Each FS downloads and extracts the ZIP
independently and cleans up when its file is closed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add mutex protection and reference counting to live photo handling:

- Add sync.Mutex to synologyLivePhotoFS to protect download
- Add refCount to track open files using shared FS
- Add verifyZipFile() to validate ZIP content before extraction
- Add detailed logging for download and extraction
- Cleanup only happens when last file is closed (refCount=0)
- Shared FS between image and video avoids duplicate downloads

This fixes "not a valid zip file" and "io: read/write on closed pipe"
errors caused by concurrent downloads and premature cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add Content-Type logging to help diagnose live photo download problems:

- DownloadLivePhotoZip now returns content-type for debugging
- Log complete curl command when ZIP validation fails
- This helps users manually reproduce and investigate issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Synology's live photo API may return either:
- A ZIP file containing both image and video (Content-Disposition: download.zip)
- A single image file (Content-Disposition: *.jpg/*.heic)

Previously the code assumed all responses were ZIP files, causing failures
when Synology returned a single file.

Changes:
- Check Content-Disposition header to determine response type
- Handle ZIP responses: extract both image and video files
- Handle single file responses: save the image directly
- Add isZip flag to track response type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When Synology returns a single file (not ZIP), the video file doesn't
exist. Previously the code would still try to access the video file,
causing 'file not found' errors.

Changes:
- Add hasVideo() method to check if live photo contains video
- Only create video asset for ZIP responses (isZip=true)
- Skip video processing for single file responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The issue was that hasVideo() was called before Open() completed the
download, causing it to return false and skip video asset creation.
Later when Open() completed and found a ZIP, the video file was
missing because the asset was never created.

Changes:
- Add preDownload() method to download before creating assets
- Call preDownload() in processLivePhoto before hasVideo() check
- Open() now uses pre-downloaded files instead of downloading again

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Synology sometimes stores live photos with mismatched filenames in ZIP,
e.g., IMG_4009.MOV paired with IMG_4009_1.HEIC. The video and image
have different base names.

Changes:
- When exact video filename not found, search for any .mov/.mp4 file
- Log which alternative video file is used
- Add getAvailableFiles() for better error messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add closed flag to synologyFile and synologyLivePhotoFile to prevent
potential issues with double-close operations that could cause
"io: read/write on closed pipe" errors during upload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The 'io: read/write on closed pipe' errors were caused by temporary
files being deleted while still being read for upload.

Changes:
- Add mutex protection to synologyFile and synologyLivePhotoFile
- Add Read() method that checks if file is closed before reading
- Delay temp file deletion by 5 seconds after Close() to ensure
  all upload readers have completed
- Delay cleanup for live photo FS to prevent race conditions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Synology Photos stores Unix timestamps in local timezone, not UTC.
Previously the code used time.Unix() which returns UTC time, causing
an 8-hour offset for users in UTC+8 timezone (e.g., China).

Changes:
- Convert capture time to local timezone using .In(time.Local)
- This preserves the original capture time as displayed in Synology

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Synology stores capture time as local timezone seconds since epoch,
not UTC. For example, a photo taken at 11:33 Beijing time has
timestamp 1771932799, which is 11:33 UTC, not 11:33 CST.

Previously the code preserved local time, but Immich interprets
the time as UTC and adds timezone offset, causing 8-hour difference.

Changes:
- Interpret Synology timestamp as local time components
- Convert to UTC before sending to Immich
- This ensures Immich displays the correct local time

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…logy API

Synology's time metadata has timezone issues, and GPS coordinates may
have format differences. Immich can read these directly from EXIF
more accurately.

Changes:
- Don't set CaptureDate - let Immich read from EXIF
- Don't set Latitude/Longitude - let Immich read from EXIF
- Only set Description and FileDate (index time) from Synology

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tadata

Since we now let Immich read time and GPS directly from EXIF,
the CaptureTime function and related metadata fields are no longer needed.

Changes:
- Remove CaptureTime() function from models.go
- Remove DateTaken, Latitude, Longitude from FromApplication metadata
- Keep FileName and Description which are still used

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ywwzwb and others added 5 commits March 23, 2026 13:48
- Fix nil pointer dereference in Asset.Close(): condition was inverted,
  causing panic when cacheReader is nil (the common case before OpenFile)
- Fix nil reader dereference in preDownload: DownloadItem may return nil
  reader on error; removed the erroneous reader.Close() call
- Fix errors.Join in upload run.go: was discarding accumulated errGroup,
  now correctly joins with previous errors
- Restore album filter in processItem: --albums flag had no filtering
  effect after refactor; added matchesAlbumFilter using albumImageCache
- Fix temp file leak in processSingleItem: clean up downloaded temp file
  when mapToAsset returns nil (unsupported file type)
- Fix misleading error message: "pre-download live photo" -> "pre-download item"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Asset.Close: verify no panic when cacheReader is nil, all CloseOptions
  are invoked, and early exit on first error
- matchesAlbumFilter: cover no-filter passthrough, item-in-album,
  item-not-in-album, and multi-album scenarios
- correctTimestamp: verify UTC no-op and fixed-offset (+8h) correction

Also make matchesAlbumFilter self-contained by returning true early when
no album filter is active (previously relied solely on the call-site guard).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ywwzwb ywwzwb requested a review from simulot as a code owner March 23, 2026 06:06
… dir

Live Photo assets share a temp directory. When multiple assets from the
same directory are closed concurrently, one may delete the directory
before another attempts to list it, causing a spurious "no such file or
directory" error log. Skip cleanup if the directory is already gone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant