Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e8165b3
feat: add Synology Photos adapter for migration
ywwzwb Mar 23, 2026
238a03a
doc: update README with Synology Photos support
Mar 9, 2026
5032c39
fix: improve Synology login error handling and add debug info
Mar 9, 2026
db6e75f
fix: use acme.sh-style Synology login authentication
Mar 9, 2026
bb17246
fix: handle API error responses correctly
Mar 9, 2026
82ccace
fix: simplify error handling in Synology client
Mar 9, 2026
adcd0c8
fix synology issue
Mar 9, 2026
641651b
fix: set both Name and Value fields for tags
Mar 9, 2026
be87c11
fix: buffer downloaded files to local temp before upload
Mar 9, 2026
40edcd1
feat: support live photos (motion photos)
Mar 9, 2026
d33bd3f
fix: process ALL items when no specific albums requested
Mar 9, 2026
0c0b648
feat(synology): use ZIP download API for live photos
Mar 10, 2026
2e64413
fix(synology): fix live photo cleanup race condition
Mar 10, 2026
6fd7f94
fix(synology): fix live photo race conditions with proper sync
Mar 10, 2026
73b0a31
feat(synology): add debug logging for live photo download issues
Mar 10, 2026
9199977
fix(synology): handle both ZIP and single file responses for live photos
ywwzwb Mar 10, 2026
f45d6e5
fix(synology): only process video for ZIP live photo responses
ywwzwb Mar 10, 2026
6c3202f
fix(synology): fix race condition in live photo video detection
ywwzwb Mar 10, 2026
a3603a7
fix(synology): handle mismatched live photo filenames in ZIP
ywwzwb Mar 10, 2026
1c9d35d
fix(synology): prevent double-close of files
ywwzwb Mar 11, 2026
38ca0e5
fix(synology): prevent premature file deletion causing upload errors
ywwzwb Mar 11, 2026
6bcc621
fix(synology): preserve local timezone for capture time
ywwzwb Mar 11, 2026
5246d29
fix(synology): convert local time to UTC for Immich compatibility
ywwzwb Mar 11, 2026
327da5d
fix(synology): let Immich read time and GPS from EXIF instead of Syno…
ywwzwb Mar 11, 2026
897f59f
cleanup(synology): remove unused CaptureTime function and GPS/time me…
ywwzwb Mar 11, 2026
0d70ed7
feat: add log and remove unsed function
ywwzwb Mar 14, 2026
366726b
fix(synology): add logs
ywwzwb Mar 14, 2026
dc5a82a
fix: format
ywwzwb Mar 15, 2026
d9abe40
feat(synology): add log
ywwzwb Mar 15, 2026
57daec8
feat(synology): remove unused additional fileds, and add tags
ywwzwb Mar 15, 2026
0004181
feat(synology): fix time stamp
ywwzwb Mar 15, 2026
f9746ef
feat(Log): add source
ywwzwb Mar 15, 2026
6c408f3
feat(Synology): fix album issue
ywwzwb Mar 15, 2026
46e84c4
fix: address critical bugs in Synology adapter and asset Close logic
ywwzwb Mar 23, 2026
ef8c141
test: add unit tests for Asset.Close and Synology filter/timestamp logic
ywwzwb Mar 23, 2026
713d32b
fix(synology): avoid spurious error log when cleaning live photo temp…
ywwzwb Apr 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
790 changes: 790 additions & 0 deletions adapters/synology/client.go

Large diffs are not rendered by default.

134 changes: 134 additions & 0 deletions adapters/synology/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package synology

import (
"context"
"fmt"

"github.com/simulot/immich-go/adapters"
"github.com/simulot/immich-go/app"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

// FromSynologyCmd holds the configuration for the from-synology command
type FromSynologyCmd struct {
// Synology connection settings
SynologyURL string
SynologyUser string
SynologyPass string
IncludeShared bool
InsecureSSL bool

// Filters
Albums []string
Tags []string
People []string
SkipFaceData bool

// Internal
app *app.Application
adapter *Adapter
}

// RegisterFlags registers CLI flags for the command
func (cmd *FromSynologyCmd) RegisterFlags(flags *pflag.FlagSet) {
// Connection flags
flags.StringVar(&cmd.SynologyURL, "synology-url", "", "Synology Photos URL (e.g., https://nas:5001 or https://nas/photo)")
flags.StringVar(&cmd.SynologyUser, "synology-user", "", "Synology username")
flags.StringVar(&cmd.SynologyPass, "synology-pass", "", "Synology password")
flags.BoolVar(&cmd.IncludeShared, "include-shared-space", false, "Include shared space (FotoTeam)")
flags.BoolVar(&cmd.InsecureSSL, "insecure-ssl", true, "Skip SSL certificate verification")

// Filter flags
flags.StringSliceVar(&cmd.Albums, "from-albums", nil, "Import only from these albums (comma-separated)")
flags.StringSliceVar(&cmd.Tags, "from-tags", nil, "Import only items with these tags (comma-separated)")
flags.StringSliceVar(&cmd.People, "from-people", nil, "Import only items with these people (comma-separated)")
flags.BoolVar(&cmd.SkipFaceData, "skip-face-data", false, "Skip face recognition data")
}

// NewFromSynologyCommand creates a new Cobra command for importing from Synology Photos
func NewFromSynologyCommand(ctx context.Context, parent *cobra.Command, app *app.Application, runner adapters.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "from-synology [flags]",
Short: "Import photos from Synology Photos",
Long: `Import photos, videos, albums, and tags from a Synology Photos server.

This command connects to a Synology NAS running Synology Photos and imports
your media library into Immich. It supports:

- Photos and videos from Personal Space
- Albums (normal albums, not conditional albums)
- Tags/labels
- Face recognition data (stored as tags)
- GPS coordinates and descriptions

Examples:
# Import all photos from Synology
immich-go upload from-synology --server=http://immich:2283 --api-key=xxx \\
--synology-url=https://nas:5001 --synology-user=admin --synology-pass=secret

# Import specific albums only
immich-go upload from-synology --server=http://immich:2283 --api-key=xxx \\
--synology-url=https://nas:5001 --synology-user=admin --synology-pass=secret \\
--from-albums="Vacation 2023,Birthday Party"

# Import items with specific tags
immich-go upload from-synology --server=http://immich:2283 --api-key=xxx \\
--synology-url=https://nas:5001 --synology-user=admin --synology-pass=secret \\
--from-tags="family,vacation"

# Import without face recognition data
immich-go upload from-synology --server=http://immich:2283 --api-key=xxx \\
--synology-url=https://nas:5001 --synology-user=admin --synology-pass=secret \\
--skip-face-data`,
Args: cobra.NoArgs,
}

cmd.SetContext(ctx)
fsc := &FromSynologyCmd{}
fsc.RegisterFlags(cmd.Flags())

cmd.RunE = func(cmd *cobra.Command, args []string) error {
return fsc.Run(ctx, cmd, app, runner)
}

return cmd
}

// Run executes the from-synology command
func (fsc *FromSynologyCmd) Run(ctx context.Context, cmd *cobra.Command, app *app.Application, runner adapters.Runner) error {
// Validate required flags
if fsc.SynologyURL == "" {
return fmt.Errorf("--synology-url is required")
}
if fsc.SynologyUser == "" {
return fmt.Errorf("--synology-user is required")
}
if fsc.SynologyPass == "" {
return fmt.Errorf("--synology-pass is required")
}

fsc.app = app

// Create and configure the adapter
adapter := NewAdapter(app, fsc.SynologyURL, fsc.SynologyUser, fsc.SynologyPass)
adapter.IncludeShared = fsc.IncludeShared
adapter.Albums = fsc.Albums
adapter.Tags = fsc.Tags
adapter.People = fsc.People
adapter.SkipFaceData = fsc.SkipFaceData
fsc.adapter = adapter

// Open connection to Synology
app.Log().Info("Connecting to Synology Photos", "url", fsc.SynologyURL, "user", fsc.SynologyUser)
if err := adapter.Open(ctx); err != nil {
app.Log().Error("Connection failed. Common causes:", "reason", "1) Wrong URL format - use https://nas:5001 (admin port) or https://nas/photo (Photo alias). 2) Wrong username/password. 3) Account lacks permission. 4) 2FA is enabled (not supported yet)")
return fmt.Errorf("failed to connect to Synology Photos: %w", err)
}
defer adapter.Close(ctx)

app.Log().Info("Successfully connected to Synology Photos")

// Run the import
return runner.Run(cmd, adapter)
}
263 changes: 263 additions & 0 deletions adapters/synology/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
package synology

import (
"path"
"strings"
"time"
)

// LoginData represents the login response data
type LoginData struct {
DID string `json:"did"` // Device ID
SID string `json:"sid"` // Session ID
SynoToken string `json:"synotoken"` // CSRF token for some APIs
DeviceID string `json:"device_id"` // Device ID (alternative field)
}

// LoginResponse represents the response from SYNO.API.Auth login
type LoginResponse struct {
Data LoginData `json:"data"`
Success bool `json:"success"`
Error *Error `json:"error,omitempty"`
}

// APIResponse is the base response structure for most Synology APIs
type APIResponse[T any] struct {
Data T `json:"data"`
Success bool `json:"success"`
Error *Error `json:"error,omitempty"`
}

// Error represents a Synology API error
type Error struct {
Code int `json:"code"`
}

// Album represents a Synology Photos album
type Album struct {
ID int `json:"id"`
Name string `json:"name"`
ItemCount int `json:"item_count"`
OwnerUserID int `json:"owner_user_id"`
CreateTime int64 `json:"create_time"` // Unix timestamp
EndTime int64 `json:"end_time"`
StartTime int64 `json:"start_time"`
Passphrase string `json:"passphrase"`
Shared bool `json:"shared"`
TemporaryShared bool `json:"temporary_shared"`
FreezeAlbum bool `json:"freeze_album"`
SortBy string `json:"sort_by"`
SortDirection string `json:"sort_direction"`
Type string `json:"type"` // "normal" or "condition"
Version int `json:"version"`
Condition Condition `json:"condition,omitempty"`
CantMigrateCondition interface{} `json:"cant_migrate_condition,omitempty"`
}

// Condition represents album filter conditions for conditional albums
type Condition struct {
FolderFilter []int `json:"folder_filter,omitempty"`
UserID int `json:"user_id,omitempty"`
}

// AlbumListResponse represents the response from Browse.Album list method
type AlbumListResponse struct {
List []Album `json:"list"`
}

// Item represents a photo or video in Synology Photos
type Item struct {
ID int `json:"id"`
Filename string `json:"filename"`
Filesize int64 `json:"filesize"`
FolderID int `json:"folder_id"`
Time int64 `json:"time"` // Local timestamp of capture time
IndexedTime int64 `json:"indexed_time"`
Type string `json:"type"` // "photo", "video", or "live"
LiveType string `json:"live_type,omitempty"` // "photo" for live photos
OwnerUserID int `json:"owner_user_id"`
Additional Additional `json:"additional,omitempty"`
}

// Additional contains extra metadata for items
type Additional struct {
Thumbnail ThumbnailInfo `json:"thumbnail,omitempty"`
Resolution ResolutionInfo `json:"resolution,omitempty"`
Orientation int `json:"orientation,omitempty"`
OrientationOriginal int `json:"orientation_original,omitempty"`
Exif ExifInfo `json:"exif,omitempty"`
Tag []TagInfo `json:"tag,omitempty"`
Description string `json:"description,omitempty"`
GPS GPSInfo `json:"gps,omitempty"`
GeocodingID int `json:"geocoding_id,omitempty"`
Address AddressInfo `json:"address,omitempty"`
Person []PersonInfo `json:"person,omitempty"`
VideoConvert interface{} `json:"video_convert,omitempty"`
VideoMeta interface{} `json:"video_meta,omitempty"`
ProviderUserID int `json:"provider_user_id,omitempty"`
}

// ThumbnailInfo contains thumbnail availability information
type ThumbnailInfo struct {
CacheKey string `json:"cache_key"`
UnitID int `json:"unit_id"`
SM string `json:"sm"` // "ready" or "broken"
M string `json:"m"` // "ready" or "broken"
XL string `json:"xl"` // "ready" or "broken"
Preview string `json:"preview"` // "ready" or "broken"
}

// ResolutionInfo contains image/video resolution
type ResolutionInfo struct {
Height int `json:"height"`
Width int `json:"width"`
}

// ExifInfo contains EXIF metadata
type ExifInfo struct {
Aperture string `json:"aperture,omitempty"`
Camera string `json:"camera,omitempty"`
ExposureTime string `json:"exposure_time,omitempty"`
FocalLength string `json:"focal_length,omitempty"`
ISO int `json:"iso,omitempty"`
Lens string `json:"lens,omitempty"`
}

// TagInfo represents a tag assigned to an item
type TagInfo struct {
ID int `json:"id"`
Name string `json:"name"`
}

// GPSInfo contains GPS coordinates
type GPSInfo struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}

// AddressInfo contains reverse geocoding address
type AddressInfo struct {
City string `json:"city,omitempty"`
CityID int `json:"city_id,omitempty"`
Country string `json:"country,omitempty"`
CountryID int `json:"country_id,omitempty"`
County string `json:"county,omitempty"`
CountyID int `json:"county_id,omitempty"`
District string `json:"district,omitempty"`
DistrictID int `json:"district_id,omitempty"`
Landmark string `json:"landmark,omitempty"`
LandmarkID int `json:"landmark_id,omitempty"`
Route string `json:"route,omitempty"`
RouteID int `json:"route_id,omitempty"`
State string `json:"state,omitempty"`
StateID int `json:"state_id,omitempty"`
Town string `json:"town,omitempty"`
TownID int `json:"town_id,omitempty"`
Village string `json:"village,omitempty"`
VillageID int `json:"village_id,omitempty"`
}

// PersonInfo represents a recognized person in Synology Photos
type PersonInfo struct {
ID int `json:"id"`
Name string `json:"name"`
}

// ItemListResponse represents the response from Browse.Item list method
type ItemListResponse struct {
List []Item `json:"list"`
}

// Tag represents a general tag in Synology Photos
type Tag struct {
ID int `json:"id"`
Name string `json:"name"`
ItemCount int `json:"item_count,omitempty"`
}

// TagListResponse represents the response from Browse.GeneralTag list method
type TagListResponse struct {
List []Tag `json:"list"`
}

// Person represents a person in the face recognition system
type Person struct {
ID int `json:"id"`
Name string `json:"name"`
ItemCount int `json:"item_count,omitempty"`
CoverItemID int `json:"cover_item_id,omitempty"`
}

// PersonListResponse represents the response from Browse.Person list method
type PersonListResponse struct {
List []Person `json:"list"`
}

// Folder represents a folder in the Personal Space
type Folder struct {
ID int `json:"id"`
Name string `json:"name"`
Parent int `json:"parent"`
OwnerUserID int `json:"owner_user_id"`
Passphrase string `json:"passphrase"`
Shared bool `json:"shared"`
SortBy string `json:"sort_by"`
SortDirection string `json:"sort_direction"`
}

// FolderListResponse represents the response from Browse.Folder list method
type FolderListResponse struct {
List []Folder `json:"list"`
}

// IndexedTime returns the indexing time as a time.Time
func (i Item) IndexedAt() time.Time {
// Indexed time is in milliseconds
return time.Unix(0, i.IndexedTime*int64(time.Millisecond))
}

// IsPhoto returns true if the item is a photo
func (i Item) IsPhoto() bool {
return i.Type == "photo"
}

// IsVideo returns true if the item is a video
func (i Item) IsVideo() bool {
return i.Type == "video"
}

// IsLivePhoto returns true if the item is a live photo
func (i Item) IsLivePhoto() bool {
return i.Type == "live"
}

// LivePhotoVideoFilename returns the inferred video filename for a live photo
// Live photos have two files: image (e.g., .HEIC) and video (e.g., .MOV)
// with the same basename
func (i Item) LivePhotoVideoFilename() string {
if !i.IsLivePhoto() {
return ""
}
ext := strings.ToLower(path.Ext(i.Filename))
basename := i.Filename[:len(i.Filename)-len(ext)]

// Map of image extensions to video extensions (common for live photos)
videoExts := map[string]string{
".heic": ".mov",
".jpg": ".mov",
".jpeg": ".mov",
".png": ".mov",
}

if videoExt, ok := videoExts[ext]; ok {
return basename + videoExt
}

// Default to .mov if extension not recognized
return basename + ".mov"
}

// CreateTime returns the album creation time as time.Time
func (a Album) CreateAt() time.Time {
return time.Unix(a.CreateTime, 0)
}
Loading