diff --git a/adapters/synology/client.go b/adapters/synology/client.go new file mode 100644 index 000000000..2cba9815b --- /dev/null +++ b/adapters/synology/client.go @@ -0,0 +1,790 @@ +package synology + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + defaultTimeout = 300 * time.Second + defaultMaxRetries = 3 + defaultRetryDelay = 1 * time.Second + defaultLimit = 100 +) + +// Client is a Synology Photos API client +type Client struct { + baseURL string + account string + password string + sid string + did string + synotoken string // Required for some APIs + httpClient *http.Client + maxRetries int + retryDelay time.Duration + logger *slog.Logger // Optional logger for debugging +} + +// ClientOption is a functional option for the Client +type ClientOption func(*Client) + +// WithHTTPClient sets a custom HTTP client +func WithHTTPClient(httpClient *http.Client) ClientOption { + return func(c *Client) { + c.httpClient = httpClient + } +} + +// WithInsecureSkipVerify skips SSL certificate verification +func WithInsecureSkipVerify(skip bool) ClientOption { + return func(c *Client) { + if transport, ok := c.httpClient.Transport.(*http.Transport); ok { + transport.TLSClientConfig.InsecureSkipVerify = skip + } + } +} + +// WithTimeout sets the HTTP client timeout +func WithTimeout(timeout time.Duration) ClientOption { + return func(c *Client) { + c.httpClient.Timeout = timeout + } +} + +// WithRetries sets the maximum number of retries +func WithRetries(maxRetries int) ClientOption { + return func(c *Client) { + c.maxRetries = maxRetries + } +} + +// WithLogger sets a logger for debugging +func WithLogger(logger *slog.Logger) ClientOption { + return func(c *Client) { + c.logger = logger + } +} + +// NewClient creates a new Synology Photos API client +func NewClient(baseURL, account, password string, opts ...ClientOption) (*Client, error) { + // Clean up the base URL + baseURL = strings.TrimRight(baseURL, "/") + + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, // Enable HTTP_PROXY/HTTPS_PROXY support + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // Default to insecure for self-signed certs + }, + MaxIdleConns: 10, + IdleConnTimeout: 90 * time.Second, + MaxIdleConnsPerHost: 10, + } + + client := &Client{ + baseURL: baseURL, + account: account, + password: password, + httpClient: &http.Client{Timeout: defaultTimeout, Transport: transport}, + maxRetries: defaultMaxRetries, + retryDelay: defaultRetryDelay, + } + + // Apply options + for _, opt := range opts { + opt(client) + } + + return client, nil +} + +// APIInfo represents the SYNO.API.Info response +type APIInfo struct { + Path string `json:"path"` + MinVersion int `json:"minVersion"` + MaxVersion int `json:"maxVersion"` +} + +// QueryAPIInfo queries available APIs and returns API paths and versions +func (c *Client) QueryAPIInfo(ctx context.Context, apiName string) (*APIInfo, error) { + params := url.Values{ + "api": {"SYNO.API.Info"}, + "version": {"1"}, + "method": {"query"}, + "query": {apiName}, + } + + var resp APIResponse[map[string]APIInfo] + if err := c.doRequestNoAuth(ctx, http.MethodGet, "/webapi/query.cgi", params, &resp); err != nil { + return nil, fmt.Errorf("query API info failed: %w", err) + } + + if !resp.Success { + return nil, fmt.Errorf("query API info failed: error %d", resp.Error.Code) + } + c.logger.Debug("API info query successful", "apiName", apiName, "data", resp.Data) + if apiInfo, ok := resp.Data[apiName]; ok { + return &apiInfo, nil + } + + return nil, fmt.Errorf("API %s not found", apiName) +} + +// Login authenticates with the Synology server +func (c *Client) Login(ctx context.Context) error { + // First, query API info to get the correct path and version + apiInfo, err := c.QueryAPIInfo(ctx, "SYNO.API.Auth") + if err != nil { + // Fallback to default values + apiInfo = &APIInfo{ + Path: "auth.cgi", + MaxVersion: 6, + } + } + + // Build login parameters according to DSM API spec + params := url.Values{ + "api": {"SYNO.API.Auth"}, + "version": {strconv.Itoa(apiInfo.MaxVersion)}, + "method": {"login"}, + "account": {c.account}, + "passwd": {c.password}, + "format": {"sid"}, + "enable_syno_token": {"yes"}, + } + + // Use the correct API path from API info + authPath := "/webapi/" + apiInfo.Path + + var resp LoginResponse + if err := c.doRequestNoAuth(ctx, http.MethodGet, authPath, params, &resp); err != nil { + return fmt.Errorf("login failed: %w", err) + } + + c.sid = resp.Data.SID + c.did = resp.Data.DID + c.synotoken = resp.Data.SynoToken + if c.did == "" { + c.did = resp.Data.DeviceID // Fallback to device_id if did is empty + } + mask := func(s string) string { + if len(s) <= 6 { + return "***" + } + return s[:3] + "***" + s[len(s)-3:] + } + c.logger.Info("Synology login successful", "sid", mask(c.sid), "did", mask(c.did), "synotoken", mask(c.synotoken)) + return nil +} + +// getErrorMessage returns a human-readable error message for Synology API error codes +func (c *Client) getErrorMessage(code int) string { + switch code { + case 100: + return "Unknown error" + case 101: + return "Invalid parameters - wrong API, method, or version" + case 102: + return "The requested method does not exist" + case 103: + return "The requested method does not support the requested version" + case 104: + return "Version not supported" + case 105: + return "Session ID not found (need to login again)" + case 106: + return "Incorrect account or password" + case 107: + return "Permission denied" + case 108: + return "OTP code required (two-factor authentication)" + case 109: + return "Failed to authenticate with OTP" + case 110: + return "Max TOTP retries reached" + case 111: + return "Password change required" + case 112: + return "Strong password required" + case 113: + return "Strong password required for admin" + case 119: + return "SID not found or session timeout" + case 400: + return "Invalid credentials in request" + case 401: + return "Guest account disabled" + case 402: + return "Account disabled" + case 403: + return "Permission denied" + case 404: + return "OTP code required" + case 405: + return "OTP authenticate failed" + default: + return fmt.Sprintf("Unknown error code %d", code) + } +} + +// Logout ends the session +func (c *Client) Logout(ctx context.Context) error { + if c.sid == "" { + return nil + } + + params := url.Values{ + "api": {"SYNO.API.Auth"}, + "version": {"3"}, + "method": {"logout"}, + "_sid": {c.sid}, + } + + var resp APIResponse[struct{ Success bool }] + err := c.doRequestNoAuth(ctx, http.MethodGet, "/webapi/auth.cgi", params, &resp) + + c.sid = "" + c.did = "" + c.logger.Info("Synology session logged out") + return err +} + +// IsAuthenticated returns true if the client has a valid session +func (c *Client) IsAuthenticated() bool { + return c.sid != "" +} + +// ListAlbums retrieves a list of albums +// Note: SYNO.Foto.Browse.Album may not be available in all DSM versions +func (c *Client) ListAlbums(ctx context.Context, offset, limit int) ([]Album, error) { + if limit <= 0 { + limit = defaultLimit + } + + // Try SYNO.Foto.Browse.Album first (version 1) + params := url.Values{ + "api": {"SYNO.Foto.Browse.Album"}, + "version": {"1"}, + "method": {"list"}, + "offset": {strconv.Itoa(offset)}, + "limit": {strconv.Itoa(limit)}, + } + + var resp APIResponse[AlbumListResponse] + err := c.doAuthenticatedRequest(ctx, http.MethodPost, "/webapi/entry.cgi", params, nil, &resp) + if err != nil { + // If Album API fails, try getting items from timeline instead + return nil, fmt.Errorf("album API not available: %w", err) + } + + return resp.Data.List, nil +} + +// GetAlbumItems retrieves items in a specific album +func (c *Client) GetAlbumItems(ctx context.Context, albumID int, offset, limit int, additional []string) ([]Item, error) { + if limit <= 0 { + limit = defaultLimit + } + + params := url.Values{ + "api": {"SYNO.Foto.Browse.Item"}, + "version": {"4"}, + "method": {"list"}, + "offset": {strconv.Itoa(offset)}, + "limit": {strconv.Itoa(limit)}, + "album_id": {strconv.Itoa(albumID)}, + } + + if len(additional) > 0 { + additionalJSON, _ := json.Marshal(additional) + params.Set("additional", string(additionalJSON)) + } + + var resp APIResponse[ItemListResponse] + if err := c.doAuthenticatedRequest(ctx, http.MethodPost, "/webapi/entry.cgi", params, nil, &resp); err != nil { + return nil, err + } + + return resp.Data.List, nil +} + +// ListItems retrieves items from folders in the Personal Space +func (c *Client) ListItems(ctx context.Context, folderID *int, offset, limit int, additional []string) ([]Item, error) { + if limit <= 0 { + limit = defaultLimit + } + + params := url.Values{ + "api": {"SYNO.Foto.Browse.Item"}, + "version": {"4"}, + "method": {"list"}, + "offset": {strconv.Itoa(offset)}, + "limit": {strconv.Itoa(limit)}, + } + + if folderID != nil { + params.Set("folder_id", strconv.Itoa(*folderID)) + } + + if len(additional) > 0 { + additionalJSON, _ := json.Marshal(additional) + params.Set("additional", string(additionalJSON)) + } + + var resp APIResponse[ItemListResponse] + if err := c.doAuthenticatedRequest(ctx, http.MethodPost, "/webapi/entry.cgi", params, nil, &resp); err != nil { + return nil, err + } + + return resp.Data.List, nil +} + +// ListAllItems retrieves all items (no folder filter) +func (c *Client) ListAllItems(ctx context.Context, offset, limit int, additional []string) ([]Item, error) { + return c.ListItems(ctx, nil, offset, limit, additional) +} + +// GetItemDetails retrieves detailed information about a specific item +func (c *Client) GetItemDetails(ctx context.Context, itemID int, additional []string) (*Item, error) { + params := url.Values{ + "api": {"SYNO.Foto.Browse.Item"}, + "version": {"1"}, + "method": {"get"}, + "id": {strconv.Itoa(itemID)}, + } + + if len(additional) > 0 { + additionalJSON, _ := json.Marshal(additional) + params.Set("additional", string(additionalJSON)) + } + + var resp APIResponse[Item] + if err := c.doAuthenticatedRequest(ctx, http.MethodGet, "/webapi/entry.cgi", params, nil, &resp); err != nil { + return nil, err + } + + return &resp.Data, nil +} + +// ListTags retrieves all general tags +func (c *Client) ListTags(ctx context.Context, offset, limit int) ([]Tag, error) { + if limit <= 0 { + limit = defaultLimit + } + + params := url.Values{ + "api": {"SYNO.Foto.Browse.GeneralTag"}, + "version": {"1"}, + "method": {"list"}, + "offset": {strconv.Itoa(offset)}, + "limit": {strconv.Itoa(limit)}, + } + + var resp APIResponse[TagListResponse] + if err := c.doAuthenticatedRequest(ctx, http.MethodPost, "/webapi/entry.cgi", params, nil, &resp); err != nil { + return nil, err + } + + return resp.Data.List, nil +} + +// ListPeople retrieves all people (face recognition) +func (c *Client) ListPeople(ctx context.Context, offset, limit int) ([]Person, error) { + if limit <= 0 { + limit = defaultLimit + } + + params := url.Values{ + "api": {"SYNO.Foto.Browse.Person"}, + "version": {"1"}, + "method": {"list"}, + "offset": {strconv.Itoa(offset)}, + "limit": {strconv.Itoa(limit)}, + } + + var resp APIResponse[PersonListResponse] + if err := c.doAuthenticatedRequest(ctx, http.MethodGet, "/webapi/entry.cgi", params, nil, &resp); err != nil { + return nil, err + } + + return resp.Data.List, nil +} + +// DownloadItem downloads the original file +func (c *Client) DownloadItem(ctx context.Context, itemID int, cacheKey string) (io.ReadCloser, error) { + params := url.Values{ + "api": {"SYNO.Foto.Download"}, + "version": {"1"}, + "method": {"download"}, + "unit_id": {fmt.Sprintf("[%d]", itemID)}, + "cache_key": {cacheKey}, + } + c.logger.Debug("start download item", "itemID", itemID, "cacheKey", cacheKey) + // Build request URL + reqURL, err := url.JoinPath(c.baseURL, "/webapi/entry.cgi") + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + var lastErr error + for attempt := 0; attempt < c.maxRetries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + c.logger.Debug("context done error", "err", err) + return nil, ctx.Err() + case <-time.After(c.retryDelay * time.Duration(attempt)): + } + } + + reqBody := strings.NewReader(params.Encode()) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, reqBody) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + // Set required headers + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + req.Header.Set("Accept", "*/*") + if c.synotoken != "" { + req.Header.Set("X-SYNO-TOKEN", c.synotoken) + } + if c.sid != "" { + req.Header.Set("Cookie", fmt.Sprintf("id=%s", c.sid)) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + c.logger.Debug("http error", "err", err) + lastErr = err + continue + } + + // Check if response is JSON error + contentType := resp.Header.Get("Content-Type") + if strings.Contains(contentType, "application/json") { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var errResp struct { + Success bool `json:"success"` + Error *Error `json:"error,omitempty"` + } + if json.Unmarshal(body, &errResp) == nil && !errResp.Success && errResp.Error != nil { + if errResp.Error.Code == 119 && attempt < c.maxRetries-1 { + // Session expired, re-login and retry + if err := c.Login(ctx); err != nil { + lastErr = fmt.Errorf("re-login failed: %w", err) + continue + } + continue + } + return nil, fmt.Errorf("download API error %d: %s, raw: %s", errResp.Error.Code, c.getErrorMessage(errResp.Error.Code), string(body)) + } + return nil, fmt.Errorf("download failed: %s", string(body)) + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + lastErr = fmt.Errorf("http %d", resp.StatusCode) + continue + } + + return resp.Body, nil + } + + return nil, fmt.Errorf("download failed after retries: %w", lastErr) +} + +// DownloadLivePhoto downloads a live photo using the source download API. +// Returns the response body, content-type, and whether it's a ZIP file (based on Content-Disposition). +// Note: Synology may return either a ZIP (containing both image and video) or just the image file. +func (c *Client) DownloadLivePhoto(ctx context.Context, itemID int, filename string) (io.ReadCloser, string, bool, error) { + params := url.Values{ + "api": {"SYNO.Foto.Download"}, + "version": {"2"}, + "method": {"download"}, + "item_id": {fmt.Sprintf("[%d]", itemID)}, + "download_type": {"source"}, + "force_download": {"true"}, + } + // Build request URL with filename in path (like browser does) + reqURL, err := url.JoinPath(c.baseURL, "/webapi/entry.cgi", filename) + if err != nil { + return nil, "", false, fmt.Errorf("invalid URL: %w", err) + } + + // Add synotoken as query param if available + if c.synotoken != "" { + reqURL = reqURL + "?SynoToken=" + url.QueryEscape(c.synotoken) + } + + var lastErr error + for attempt := 0; attempt < c.maxRetries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, "", false, ctx.Err() + case <-time.After(c.retryDelay * time.Duration(attempt)): + } + } + + reqBody := strings.NewReader(params.Encode()) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, reqBody) + if err != nil { + return nil, "", false, fmt.Errorf("create request: %w", err) + } + + // Set required headers + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + req.Header.Set("Accept", "*/*") + if c.synotoken != "" { + req.Header.Set("X-SYNO-TOKEN", c.synotoken) + } + if c.sid != "" { + req.Header.Set("Cookie", fmt.Sprintf("id=%s", c.sid)) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = err + c.logger.Error("Download failed, retrying", "attempt", attempt, "error", err, "item", itemID, "filename", filename) + continue + } + + // Check if response is JSON error + contentType := resp.Header.Get("Content-Type") + if strings.Contains(contentType, "application/json") { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var errResp struct { + Success bool `json:"success"` + Error *Error `json:"error,omitempty"` + } + if json.Unmarshal(body, &errResp) == nil && !errResp.Success && errResp.Error != nil { + if errResp.Error.Code == 119 && attempt < c.maxRetries-1 { + // Session expired, re-login and retry + if err := c.Login(ctx); err != nil { + lastErr = fmt.Errorf("re-login failed: %w", err) + continue + } + continue + } + return nil, "", false, fmt.Errorf("download API error %d: %s, raw: %s", errResp.Error.Code, c.getErrorMessage(errResp.Error.Code), string(body)) + } + return nil, "", false, fmt.Errorf("download failed: %s", string(body)) + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + lastErr = fmt.Errorf("http %d", resp.StatusCode) + continue + } + + // Check if response is a ZIP by looking at Content-Disposition header + contentDisposition := resp.Header.Get("Content-Disposition") + isZip := strings.HasSuffix(strings.ToLower(contentDisposition), ".zip") || + strings.Contains(strings.ToLower(contentDisposition), "filename=\"download.zip\"") || + strings.Contains(strings.ToLower(contentDisposition), "filename*=utf-8''download.zip") + + if c.logger != nil { + c.logger.Debug("Downloaded live photo", "item_id", itemID, "content_type", contentType, + "content_disposition", contentDisposition, "is_zip", isZip, "content_length", resp.Header.Get("Content-Length")) + } + return resp.Body, contentType, isZip, nil + } + + return nil, "", false, fmt.Errorf("download failed after retries: %w", lastErr) +} + +// doRequestNoAuth performs an HTTP request without authentication (used for login and API info) +func (c *Client) doRequestNoAuth(ctx context.Context, method, path string, params url.Values, result interface{}) error { + reqURL, err := url.JoinPath(c.baseURL, path) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + if len(params) > 0 { + reqURL = reqURL + "?" + params.Encode() + } + + req, err := http.NewRequestWithContext(ctx, method, reqURL, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("http request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("http %d: %s", resp.StatusCode, string(respBody)) + } + + if result != nil { + if err := json.Unmarshal(respBody, result); err != nil { + return fmt.Errorf("parse result: %w", err) + } + } + + return nil +} + +// doAuthenticatedRequest makes an authenticated API request +func (c *Client) doAuthenticatedRequest(ctx context.Context, method, path string, params url.Values, body io.Reader, result interface{}) error { + // Ensure we're authenticated + if !c.IsAuthenticated() { + if err := c.Login(ctx); err != nil { + return err + } + } + + return c.doRequestWithAuth(ctx, method, path, params, body, result) +} + +// doRequestWithAuth makes a request with authentication headers +func (c *Client) doRequestWithAuth(ctx context.Context, method, path string, params url.Values, body io.Reader, result interface{}) error { + for attempt := 0; attempt < c.maxRetries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(c.retryDelay * time.Duration(attempt)): + } + } + + reqURL, err := url.JoinPath(c.baseURL, path) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + // Clone params and add auth + reqParams := url.Values{} + for k, v := range params { + reqParams[k] = v + } + + // For POST requests, put params in body; for GET, put in URL + var reqBody io.Reader + var contentType string + if method == http.MethodPost { + reqBody = strings.NewReader(reqParams.Encode()) + contentType = "application/x-www-form-urlencoded; charset=UTF-8" + } else { + if len(reqParams) > 0 { + reqURL = reqURL + "?" + reqParams.Encode() + } + } + + req, err := http.NewRequestWithContext(ctx, method, reqURL, reqBody) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + // Add X-SYNO-TOKEN header for CSRF protection + if c.synotoken != "" { + req.Header.Set("X-SYNO-TOKEN", c.synotoken) + } + + // Add Cookie with session ID (required for some APIs) + if c.sid != "" { + req.Header.Set("Cookie", fmt.Sprintf("id=%s", c.sid)) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + if attempt < c.maxRetries-1 { + continue + } + return fmt.Errorf("http request failed: %w", err) + } + + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + if attempt < c.maxRetries-1 { + continue + } + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + if attempt < c.maxRetries-1 { + continue + } + return fmt.Errorf("http %d: %s", resp.StatusCode, string(respBody)) + } + + // Debug logging + if c.logger != nil { + c.logger.Debug("synology api call", "method", method, "url", reqURL, "response", string(respBody)) + } + + // Parse response + var baseResp struct { + Success bool `json:"success"` + Error *Error `json:"error,omitempty"` + } + + if err := json.Unmarshal(respBody, &baseResp); err != nil { + if attempt < c.maxRetries-1 { + continue + } + return fmt.Errorf("parse response: %w", err) + } + + // Handle session expiration - re-login and retry + if !baseResp.Success && baseResp.Error != nil && baseResp.Error.Code == 119 { + c.sid = "" + if err := c.Login(ctx); err != nil { + if attempt < c.maxRetries-1 { + continue + } + return fmt.Errorf("re-login after session expired: %w", err) + } + continue + } + + // Handle other API errors + if !baseResp.Success { + if baseResp.Error != nil { + return fmt.Errorf("synology api error %d: %s", baseResp.Error.Code, c.getErrorMessage(baseResp.Error.Code)) + } + return fmt.Errorf("synology api error: success=false") + } + + // Parse result + if result != nil { + if err := json.Unmarshal(respBody, result); err != nil { + return fmt.Errorf("parse result: %w", err) + } + } + + return nil + } + + return fmt.Errorf("max retries exceeded") +} diff --git a/adapters/synology/cmd.go b/adapters/synology/cmd.go new file mode 100644 index 000000000..e007c32ba --- /dev/null +++ b/adapters/synology/cmd.go @@ -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) +} diff --git a/adapters/synology/models.go b/adapters/synology/models.go new file mode 100644 index 000000000..94dded32f --- /dev/null +++ b/adapters/synology/models.go @@ -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) +} diff --git a/adapters/synology/synology.go b/adapters/synology/synology.go new file mode 100644 index 000000000..910203fb0 --- /dev/null +++ b/adapters/synology/synology.go @@ -0,0 +1,819 @@ +package synology + +import ( + "archive/zip" + "context" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/simulot/immich-go/app" + "github.com/simulot/immich-go/internal/assets" + "github.com/simulot/immich-go/internal/fileevent" + "github.com/simulot/immich-go/internal/fileprocessor" + "github.com/simulot/immich-go/internal/filetypes" + "github.com/simulot/immich-go/internal/fshelper" + + mapset "github.com/deckarep/golang-set/v2" +) + +// Adapter implements the adapters.Reader interface for Synology Photos +type Adapter struct { + // Configuration + ServerURL string + Account string + Password string + IncludeShared bool + Albums []string // Filter: import only these albums + Tags []string // Filter: import only items with these tags + People []string // Filter: import only items with these people + SkipFaceData bool // Skip face recognition data + + // Internal + client *Client + app *app.Application + processor *fileprocessor.FileProcessor + albumCache map[string]Album // album name -> album + tagCache map[string]int // tag name -> tag id + peopleCache map[string]int // person name -> person id + albumImageCache map[string]mapset.Set[int] //album name -> set of image ids +} + +// NewAdapter creates a new Synology Photos adapter +func NewAdapter(app *app.Application, serverURL, account, password string) *Adapter { + return &Adapter{ + ServerURL: serverURL, + Account: account, + Password: password, + app: app, + processor: app.FileProcessor(), + albumCache: make(map[string]Album), + tagCache: make(map[string]int), + peopleCache: make(map[string]int), + albumImageCache: make(map[string]mapset.Set[int]), + } +} + +// Open initializes the connection to Synology Photos +func (sa *Adapter) Open(ctx context.Context) error { + client, err := NewClient(sa.ServerURL, sa.Account, sa.Password, + WithInsecureSkipVerify(true), + WithLogger(sa.app.Log().Logger), + ) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + + // Query API info first to verify connection + if _, err := client.QueryAPIInfo(ctx, "SYNO.API.Auth"); err != nil { + sa.app.Log().Warn("Failed to query API info", "error", err, "tip", "check if URL is correct and Synology is accessible") + } + + if err := client.Login(ctx); err != nil { + return fmt.Errorf("login: %w", err) + } + + sa.client = client + return nil +} + +// Close closes the connection +func (sa *Adapter) Close(ctx context.Context) error { + if sa.client != nil { + return sa.client.Logout(ctx) + } + return nil +} + +// Browse implements the adapters.Reader interface +func (sa *Adapter) Browse(ctx context.Context) chan *assets.Group { + gOut := make(chan *assets.Group) + + go func() { + defer close(gOut) + + // Build caches if filtering + if err := sa.buildCaches(ctx); err != nil { + sa.app.Log().Error("Failed to build caches", "error", err) + return + } + + // Determine which albums to process + albumsToProcess, err := sa.getAlbumsToProcess(ctx) + if err != nil { + sa.app.Log().Error("Failed to get albums", "error", err) + return + } + + // list all image of albums + for _, album := range albumsToProcess { + if err := sa.fetchAlbumImages(ctx, album); err != nil { + sa.app.Log().Error("Failed to fetch album images", "album", album.Name, "error", err) + continue + } + } + // No specific albums requested - process ALL items + // This includes both album items and non-album items + if err := sa.processAllItems(ctx, gOut); err != nil { + sa.app.Log().Error("Failed to process items", "error", err) + return + } + }() + + return gOut +} + +// buildCaches builds tag and people caches for filtering +func (sa *Adapter) buildCaches(ctx context.Context) error { + // Build tag cache if filtering by tags + if len(sa.Tags) > 0 { + offset := 0 + for { + tags, err := sa.client.ListTags(ctx, offset, 1000) + if err != nil { + return fmt.Errorf("list tags: %w", err) + } + for _, tag := range tags { + sa.tagCache[tag.Name] = tag.ID + } + if len(tags) < 1000 { + break + } + offset += 1000 + } + } + + // Build people cache if filtering by people + if len(sa.People) > 0 { + offset := 0 + for { + people, err := sa.client.ListPeople(ctx, offset, 1000) + if err != nil { + return fmt.Errorf("list people: %w", err) + } + for _, person := range people { + sa.peopleCache[person.Name] = person.ID + } + if len(people) < 1000 { + break + } + offset += 1000 + } + } + + return nil +} + +// getAlbumsToProcess returns the list of albums to import +// Note: SYNO.Foto.Browse.Album may not be available in some DSM versions +func (sa *Adapter) getAlbumsToProcess(ctx context.Context) ([]Album, error) { + // Try to get albums, but if API doesn't exist, return empty list + // Photos will be imported without album structure + allAlbums := make([]Album, 0) + + offset := 0 + for { + albums, err := sa.client.ListAlbums(ctx, offset, 1000) + if err != nil { + // If album API is not available, just log warning and return empty + sa.app.Log().Warn("Album API not available, importing without album structure", "error", err) + return nil, nil + } + allAlbums = append(allAlbums, albums...) + if len(albums) < 1000 { + break + } + offset += 1000 + } + + // Cache albums by name + for _, album := range allAlbums { + sa.app.Log().Debug("Caching album", "name", album.Name, "id", album.ID) + sa.albumCache[album.Name] = album + } + + // If specific albums requested, filter them + if len(sa.Albums) > 0 { + filtered := make([]Album, 0, len(sa.Albums)) + for _, name := range sa.Albums { + if album, ok := sa.albumCache[name]; ok { + filtered = append(filtered, album) + } else { + sa.app.Log().Warn("Album not found", "name", name) + } + } + return filtered, nil + } + + return allAlbums, nil +} + +func (sa *Adapter) fetchAlbumImages(ctx context.Context, album Album) error { + offset := 0 + limit := 100 + for { + items, err := sa.client.GetAlbumItems(ctx, album.ID, offset, limit, nil) + if err != nil { + return fmt.Errorf("get album items: %w", err) + } + if sa.albumImageCache[album.Name] == nil { + sa.albumImageCache[album.Name] = mapset.NewSet[int]() + } + for _, item := range items { + sa.albumImageCache[album.Name].Add(item.ID) + } + if len(items) < limit { + break + } + offset += limit + } + return nil +} + +// processAllItems processes all items without album grouping +func (sa *Adapter) processAllItems(ctx context.Context, gOut chan *assets.Group) error { + sa.app.Log().Info("Processing all items") + + additional := sa.getAdditionalFields() + offset := 0 + limit := 100 + + for { + items, err := sa.client.ListAllItems(ctx, offset, limit, additional) + if err != nil { + return fmt.Errorf("list items: %w", err) + } + + for _, item := range items { + if err := sa.processItem(ctx, &item, gOut); err != nil { + sa.app.Log().Error("Failed to process item", "filename", item.Filename, "error", err) + } + } + + if len(items) < limit { + sa.app.Log().Info("All items processed") + break + } + offset += limit + } + + return nil +} + +// getAdditionalFields returns the list of additional fields to request +func (sa *Adapter) getAdditionalFields() []string { + // Always request these + additional := []string{ + "thumbnail", + "tag", + } + + // Request people if we need them + if !sa.SkipFaceData || len(sa.People) > 0 { + additional = append(additional, "person") + } + + return additional +} + +// processItem processes a single item and sends it to the output channel +// For live photos, this creates both the image and video assets +func (sa *Adapter) processItem(ctx context.Context, item *Item, gOut chan *assets.Group) error { + // Check album filter: if specific albums were requested, skip items not in any of them + if len(sa.Albums) > 0 && !sa.matchesAlbumFilter(item) { + return nil + } + + // Check tag filter + if len(sa.Tags) > 0 && !sa.matchesTagFilter(item) { + return nil + } + + // Check people filter + if len(sa.People) > 0 && !sa.matchesPeopleFilter(item) { + return nil + } + + // For live photos, we need to create two assets: image and video + if item.IsLivePhoto() { + return sa.processLivePhoto(ctx, item, gOut) + } + + // Regular single asset + return sa.processSingleItem(ctx, item, gOut) +} + +// processLivePhoto handles live photos by creating both image and video assets +// Uses Synology's ZIP download API with proper synchronization +// Note: Synology may return either a ZIP (with both image and video) or just the image file +func (sa *Adapter) processLivePhoto(ctx context.Context, item *Item, gOut chan *assets.Group) error { + sa.app.Log().Debug("Processing live photo", "filename", item.Filename, "item_id", item.ID) + + // Pre-download to determine if we have a ZIP (with video) or single file + // This is necessary because hasVideo() needs to know the response type + var livePhotoSaveDir string + var err error + if livePhotoSaveDir, err = sa.preDownloadLive(item, ctx); err != nil { + return fmt.Errorf("pre-download live photo: %w", err) + } + // _ = livePhotoSaveDir + // list all file in livePhotoSaveDir + if files, err := os.ReadDir(livePhotoSaveDir); err != nil { + return fmt.Errorf("read dir faild: %w", err) + } else { + for _, file := range files { + if !file.Type().IsRegular() { + continue + } + sa.app.Log().Debug("Get file in live photo dir", "filename", file.Name(), "item_id", item.ID) + // Map to asset + asset, mediaType := sa.mapToAssetForLive(item, filepath.Join(livePhotoSaveDir, file.Name())) + if asset == nil { + continue + } + // Record discovery + code := fileevent.DiscoveredImage + if mediaType == filetypes.TypeVideo { + code = fileevent.DiscoveredVideo + } + sa.processor.RecordAssetDiscovered(ctx, asset.File, int64(asset.FileSize), code) + // Send to output + group := assets.NewGroup(assets.GroupByNone, asset) + select { + case gOut <- group: + case <-ctx.Done(): + return ctx.Err() + } + } + } + return nil +} + +// processSingleItem processes a regular (non-live) photo or video +func (sa *Adapter) processSingleItem(ctx context.Context, item *Item, gOut chan *assets.Group) error { + var tempFilePath string + var err error + if tempFilePath, err = sa.preDownload(item, strconv.Itoa(item.ID), ctx); err != nil { + return fmt.Errorf("pre-download item: %w", err) + } + // Map to asset + asset := sa.mapToAsset(item, tempFilePath) + if asset == nil { + // Unsupported file type: clean up temp file and skip + os.Remove(tempFilePath) + return nil + } + + // Record discovery + code := fileevent.DiscoveredImage + if item.IsVideo() { + code = fileevent.DiscoveredVideo + } + sa.processor.RecordAssetDiscovered(ctx, asset.File, int64(asset.FileSize), code) + + // Send to output + group := assets.NewGroup(assets.GroupByNone, asset) + select { + case gOut <- group: + case <-ctx.Done(): + return ctx.Err() + } + + return nil +} + +// matchesTagFilter checks if the item matches the tag filter +func (sa *Adapter) matchesTagFilter(item *Item) bool { + if len(sa.Tags) == 0 { + return true + } + + itemTags := make(map[string]bool) + for _, tag := range item.Additional.Tag { + itemTags[tag.Name] = true + } + + for _, filterTag := range sa.Tags { + if itemTags[filterTag] { + return true + } + } + + return false +} + +// matchesAlbumFilter checks if the item belongs to any of the requested albums. +// Returns true when no album filter is active. +func (sa *Adapter) matchesAlbumFilter(item *Item) bool { + if len(sa.Albums) == 0 { + return true + } + for _, images := range sa.albumImageCache { + if images.Contains(item.ID) { + return true + } + } + return false +} + +// matchesPeopleFilter checks if the item matches the people filter +func (sa *Adapter) matchesPeopleFilter(item *Item) bool { + if len(sa.People) == 0 { + return true + } + + itemPeople := make(map[string]bool) + for _, person := range item.Additional.Person { + itemPeople[person.Name] = true + } + + for _, filterPerson := range sa.People { + if itemPeople[filterPerson] { + return true + } + } + + return false +} + +func (sa *Adapter) mapToAssetForLive(item *Item, actuleFilePath string) (*assets.Asset, string) { + // Determine file type + ext := strings.ToLower(path.Ext(actuleFilePath)) + mediaType := "" + if _mediaType, ok := sa.app.GetSupportedMedia()[ext]; !ok { + sa.app.Log().Warn("Unsupported file type", "filename", actuleFilePath, "ext", ext) + return nil, "" + } else { + mediaType = _mediaType + } + if mediaType != filetypes.TypeImage && mediaType != filetypes.TypeVideo { + sa.app.Log().Warn("Unsupported file type", "filename", actuleFilePath, "ext", ext) + return nil, "" + } + + fs := &SimpleFileFS{ + path: actuleFilePath, + logger: sa.app.Log().Logger, + } + + // file, err := os.Open(actuleFilePath) + captureDate := time.Unix(sa.correctTimestamp(item.Time, sa.app.GetTZ()), 0).In(sa.app.GetTZ()) + asset := &assets.Asset{ + File: fshelper.FSName(fs, item.Filename), + OriginalFileName: item.Filename, + Description: item.Additional.Description, + CaptureDate: captureDate, + } + + if stat, err := os.Stat(actuleFilePath); err != nil { + sa.app.Log().Warn("Failed to get file info", "filename", actuleFilePath, "error", err) + return nil, "" + } else { + asset.FileSize = int(stat.Size()) + } + // Add album if specified + for albumName, images := range sa.albumImageCache { + if images.Contains(item.ID) { + asset.Albums = append(asset.Albums, assets.Album{ + Title: albumName, + }) + } + } + + // Add tags (skip empty names) + for _, tag := range item.Additional.Tag { + if tag.Name != "" { + asset.Tags = append(asset.Tags, assets.Tag{ + Name: tag.Name, + Value: tag.Name, + }) + } + } + + // Add people as tags (prefixed with "Person: ") since Immich handles faces separately + // Skip empty person names + if !sa.SkipFaceData { + for _, person := range item.Additional.Person { + if person.Name != "" { + personTag := fmt.Sprintf("Person: %s", person.Name) + asset.Tags = append(asset.Tags, assets.Tag{ + Name: personTag, + Value: personTag, + }) + } + } + } + asset.AddCloseOption(func(asset *assets.Asset) error { + sa.app.Log().Debug("Removing file", "filename", actuleFilePath) + if err := os.Remove(actuleFilePath); err != nil { + sa.app.Log().Error("Error removing file", "filename", actuleFilePath, "err", err) + // ignore error + return nil + } + dir := filepath.Dir(actuleFilePath) + if _, err := os.Stat(dir); os.IsNotExist(err) { + return nil + } + if files, err := os.ReadDir(dir); err != nil { + sa.app.Log().Error("Error reading dir", "dir", dir, "err", err) + } else if len(files) == 0 { + sa.app.Log().Debug("Removing dir", "dir", dir) + if err := os.RemoveAll(dir); err != nil { + sa.app.Log().Error("Error removing dir", "dir", dir, "err", err) + } + } + // ignore error + return nil + }) + return asset, mediaType +} + +// mapToAsset converts a Synology Item to an immich-go Asset +func (sa *Adapter) mapToAsset(item *Item, actuleFilePath string) *assets.Asset { + // Determine file type + ext := strings.ToLower(path.Ext(item.Filename)) + if _mediaType, ok := sa.app.GetSupportedMedia()[ext]; !ok { + sa.app.Log().Warn("Unsupported file type", "filename", item.Filename, "ext", ext) + return nil + } else if _mediaType != filetypes.TypeImage && _mediaType != filetypes.TypeVideo { + sa.app.Log().Warn("Unsupported file type", "filename", item.Filename, "ext", ext) + return nil + } + + // Create a custom FS that can read from Synology + + fs := &SimpleFileFS{ + path: actuleFilePath, + logger: sa.app.Log().Logger, + } + captureDate := time.Unix(sa.correctTimestamp(item.Time, sa.app.GetTZ()), 0).In(sa.app.GetTZ()) + asset := &assets.Asset{ + File: fshelper.FSName(fs, item.Filename), + FileSize: int(item.Filesize), + OriginalFileName: item.Filename, + Description: item.Additional.Description, + CaptureDate: captureDate, + } + + // Add album if specified + for albumName, images := range sa.albumImageCache { + if images.Contains(item.ID) { + asset.Albums = append(asset.Albums, assets.Album{ + Title: albumName, + }) + } + } + + // Add tags (skip empty names) + for _, tag := range item.Additional.Tag { + if tag.Name != "" { + asset.Tags = append(asset.Tags, assets.Tag{ + Name: tag.Name, + Value: tag.Name, + }) + } + } + + // Add people as tags (prefixed with "Person: ") since Immich handles faces separately + // Skip empty person names + if !sa.SkipFaceData { + for _, person := range item.Additional.Person { + if person.Name != "" { + personTag := fmt.Sprintf("Person: %s", person.Name) + asset.Tags = append(asset.Tags, assets.Tag{ + Name: personTag, + Value: personTag, + }) + } + } + } + asset.AddCloseOption(func(asset *assets.Asset) error { + sa.app.Log().Debug("Removing file", "filename", item.Filename, "filepath", actuleFilePath) + if err := os.Remove(actuleFilePath); err != nil { + sa.app.Log().Error("Error removing file", "filename", item.Filename, "filepath", actuleFilePath, "err", err) + } + // ignore error + return nil + }) + return asset +} +func (sa *Adapter) correctTimestamp(wrong int64, loc *time.Location) int64 { + t := time.Unix(wrong, 0).UTC() + for i := 0; i < 2; i++ { // to fix the daylight saving time, twice is enough + _, offset := t.In(loc).Zone() + t = time.Unix(wrong-int64(offset), 0).UTC() + } + return t.Unix() +} + +// handleZipDownload processes a ZIP response containing both image and video +func (sa *Adapter) handleZipDownload(reader io.ReadCloser, tempDir string) error { + // Save ZIP to temp file + zipPath := filepath.Join(tempDir, "livephoto.zip") + zipFile, err := os.Create(zipPath) + if err != nil { + return fmt.Errorf("create zip temp file: %w", err) + } + written, err := io.Copy(zipFile, reader) + zipFile.Close() + if err != nil { + return fmt.Errorf("Fail to save zip, path:%s, %w", zipPath, err) + } + + sa.app.Log().Debug("Downloaded ZIP to temp file", "path", zipPath, "bytes", written) + // Verify ZIP by checking file header (magic number) + if err := verifyZipFile(zipPath); err != nil { + return fmt.Errorf("Fail to verify zip, path:%s, %w", zipPath, err) + } + + // Extract ZIP + zipReader, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("Fail to open zip, path:%s, %w", zipPath, err) + } + defer zipReader.Close() + + // s.zipFiles = make(map[string]string) + unzipFileCount := 0 + for _, zf := range zipReader.File { + // Extract each file + extractPath := filepath.Join(tempDir, filepath.Base(zf.Name)) + rc, err := zf.Open() + if err != nil { + sa.app.Log().Error("Error extracting file", "filename", zf.Name, "err", err) + continue + } + + extractFile, err := os.Create(extractPath) + if err != nil { + rc.Close() + sa.app.Log().Error("Error creating extract file", "filename", zf.Name, "err", err) + continue + } + + _, err = io.Copy(extractFile, rc) + rc.Close() + extractFile.Close() + + if err != nil { + sa.app.Log().Error("Error extracting file", "filename", zf.Name, "err", err) + continue + } + + sa.app.Log().Debug("Extracted live photo file", "name", zf.Name, "size", zf.UncompressedSize64) + unzipFileCount++ + } + + // Clean up ZIP file + os.Remove(zipPath) + + sa.app.Log().Debug("Live photo ZIP extracted", "files", unzipFileCount) + return nil +} + +// handleSingleFileDownload processes a single file response (just the image) +// For non-ZIP responses, we only get the image file. The video may need separate handling. +func (sa *Adapter) handleSingleFileDownload(reader io.ReadCloser, tempDir string, filename string) error { + // Save the single file + filePath := filepath.Join(tempDir, filename) + outFile, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + + written, err := io.Copy(outFile, reader) + outFile.Close() + + if err != nil { + return fmt.Errorf("save file: %w", err) + } + + sa.app.Log().Debug("Downloaded single file", "filename", filename, "bytes", written) + + return nil +} +func (sa *Adapter) preDownload(item *Item, cacheKey string, ctx context.Context) (string, error) { + sa.app.Log().Debug("Downloading Synology file", "filename", item.Filename, "item_id", item.ID, "cache_key", cacheKey) + + // Create temp file + tempFile, err := os.CreateTemp("", "synology-*-"+filepath.Base(item.Filename)) + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + defer tempFile.Close() + + // Download to temp file + reader, err := sa.client.DownloadItem(ctx, item.ID, cacheKey) + if err != nil { + sa.app.Log().Error("Error downloading Synology file on start", "filename", item.Filename, "err", err) + return "", err + } + + n, err := io.Copy(tempFile, reader) + reader.Close() + + if err != nil { + sa.app.Log().Error("Error downloading Synology file on copy", "filename", item.Filename, "err", err) + return "", fmt.Errorf("download to temp: %w", err) + } + sa.app.Log().Debug("Downloaded Synology file", "filename", item.Filename, "size", n) + + return tempFile.Name(), nil +} + +// preDownloadLive downloads the live photo ahead of time to determine the response type +// This allows hasVideo() to return the correct value before assets are created +func (sa *Adapter) preDownloadLive(item *Item, ctx context.Context) (string, error) { + + sa.app.Log().Debug("Pre-downloading live photo", "filename", item.Filename, "item_id", item.ID) + + // Create temp directory + tempDir, err := os.MkdirTemp("", "synology-livephoto-*") + if err != nil { + return "", fmt.Errorf("create temp dir: %w", err) + } + + // Download - may return ZIP or single file + reader, contentType, isZip, err := sa.client.DownloadLivePhoto(ctx, item.ID, item.Filename) + if err != nil { + os.RemoveAll(tempDir) + return "", fmt.Errorf("download live photo: %w", err) + } + sa.app.Log().Debug("Pre-downloaded live photo", "filename", item.Filename, "content_type", contentType, + "is_zip", isZip, "item_id", item.ID) + + if isZip { + // reader is a zip file, unzip all file to tempDir + // Handle ZIP download (contains mutable files) + if err := sa.handleZipDownload(reader, tempDir); err != nil { + reader.Close() + os.RemoveAll(tempDir) + return "", err + } + } else { + // Handle single file download (just the image) + if err := sa.handleSingleFileDownload(reader, tempDir, item.Filename); err != nil { + reader.Close() + os.RemoveAll(tempDir) + return "", err + } + } + reader.Close() + + return tempDir, nil +} + +// verifyZipFile checks if the file is a valid ZIP by reading its magic number +func verifyZipFile(path string) error { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open file: %w", err) + } + defer file.Close() + + // ZIP files start with PK\x03\x04 or PK\x05\x06 + magic := make([]byte, 4) + _, err = file.Read(magic) + if err != nil { + return fmt.Errorf("read magic: %w", err) + } + + // Check for ZIP local file header (PK\x03\x04) or empty archive (PK\x05\x06) + if magic[0] != 'P' || magic[1] != 'K' { + return fmt.Errorf("invalid ZIP magic bytes: %02x %02x %02x %02x", magic[0], magic[1], magic[2], magic[3]) + } + + // Check valid ZIP signatures + if magic[2] == 0x03 && magic[3] == 0x04 { + return nil // Local file header + } + if magic[2] == 0x05 && magic[3] == 0x06 { + return nil // Empty archive + } + if magic[2] == 0x07 && magic[3] == 0x08 { + return nil // Spanned archive + } + + return fmt.Errorf("invalid ZIP signature: %02x %02x", magic[2], magic[3]) +} + +type SimpleFileFS struct { + path string + logger *slog.Logger +} + +func (fs *SimpleFileFS) Open(name string) (fs.File, error) { + if file, err := os.Open(fs.path); err != nil { + fs.logger.Error("Error opening file", "filename", name, "err", err) + return nil, err + } else { + return file, nil + } +} diff --git a/adapters/synology/synology_test.go b/adapters/synology/synology_test.go new file mode 100644 index 000000000..4fa3f237f --- /dev/null +++ b/adapters/synology/synology_test.go @@ -0,0 +1,101 @@ +package synology + +import ( + "testing" + "time" + + mapset "github.com/deckarep/golang-set/v2" +) + +// newTestAdapter returns a minimal Adapter suitable for unit tests that do not +// require a network connection or an app.Application instance. +func newTestAdapter() *Adapter { + return &Adapter{ + albumCache: make(map[string]Album), + tagCache: make(map[string]int), + peopleCache: make(map[string]int), + albumImageCache: make(map[string]mapset.Set[int]), + } +} + +// --------------------------------------------------------------------------- +// matchesAlbumFilter +// --------------------------------------------------------------------------- + +func TestMatchesAlbumFilter_NoAlbumsRequested(t *testing.T) { + sa := newTestAdapter() + // Albums slice is empty → no filter, every item passes + item := &Item{ID: 1} + if !sa.matchesAlbumFilter(item) { + t.Error("expected item to pass when no albums are requested") + } +} + +func TestMatchesAlbumFilter_ItemInRequestedAlbum(t *testing.T) { + sa := newTestAdapter() + sa.Albums = []string{"Vacation"} + sa.albumImageCache["Vacation"] = mapset.NewSet(1, 2, 3) + + item := &Item{ID: 2} + if !sa.matchesAlbumFilter(item) { + t.Error("expected item to pass when it belongs to a requested album") + } +} + +func TestMatchesAlbumFilter_ItemNotInRequestedAlbum(t *testing.T) { + sa := newTestAdapter() + sa.Albums = []string{"Vacation"} + sa.albumImageCache["Vacation"] = mapset.NewSet(1, 2, 3) + + item := &Item{ID: 99} + if sa.matchesAlbumFilter(item) { + t.Error("expected item to be filtered out when it does not belong to any requested album") + } +} + +func TestMatchesAlbumFilter_ItemInOneOfMultipleAlbums(t *testing.T) { + sa := newTestAdapter() + sa.Albums = []string{"Vacation", "Family"} + sa.albumImageCache["Vacation"] = mapset.NewSet(1, 2) + sa.albumImageCache["Family"] = mapset.NewSet(3, 4) + + item := &Item{ID: 3} + if !sa.matchesAlbumFilter(item) { + t.Error("expected item to pass when it belongs to at least one requested album") + } +} + +// --------------------------------------------------------------------------- +// correctTimestamp +// --------------------------------------------------------------------------- + +func TestCorrectTimestamp_UTC(t *testing.T) { + sa := newTestAdapter() + // In UTC there is no offset, so the timestamp should be unchanged. + now := time.Now().Unix() + got := sa.correctTimestamp(now, time.UTC) + if got != now { + t.Errorf("UTC: expected %d, got %d", now, got) + } +} + +func TestCorrectTimestamp_FixedOffset(t *testing.T) { + sa := newTestAdapter() + // Use a fixed +8h timezone (Asia/Shanghai-like). + // Synology stores local time as a Unix timestamp, so the "wrong" value is + // actually local-time seconds since epoch. correctTimestamp should subtract + // the UTC offset to obtain the real UTC timestamp. + loc := time.FixedZone("UTC+8", 8*60*60) + + // Pick a reference time: 2024-01-15 12:00:00 UTC + realUTC := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC).Unix() + // Synology would store 2024-01-15 20:00:00 as if it were a UTC timestamp + synologyWrong := realUTC + int64(8*60*60) + + got := sa.correctTimestamp(synologyWrong, loc) + if got != realUTC { + t.Errorf("UTC+8: expected %d (%s), got %d (%s)", + realUTC, time.Unix(realUTC, 0).UTC(), + got, time.Unix(got, 0).UTC()) + } +} diff --git a/app/log.go b/app/log.go index fa8bcd6f9..f7ca30356 100644 --- a/app/log.go +++ b/app/log.go @@ -167,6 +167,7 @@ func (log *Log) setHandlers(file, con io.Writer) { TimeFormat: time.DateTime, NoColor: true, Theme: console.NewDefaultTheme(), + AddSource: true, })) } @@ -178,6 +179,7 @@ func (log *Log) setHandlers(file, con io.Writer) { TimeFormat: time.DateTime, NoColor: false, Theme: console.NewDefaultTheme(), + AddSource: true, })) } diff --git a/app/upload/run.go b/app/upload/run.go index d79961876..074d0e702 100644 --- a/app/upload/run.go +++ b/app/upload/run.go @@ -317,7 +317,10 @@ func (uc *UpCmd) handleGroup(ctx context.Context, g *assets.Group) error { // Upload assets from the group for _, a := range g.Assets { err := uc.handleAsset(ctx, a) - errGroup = errors.Join(err) + if err != nil { + uc.app.Log().Error("can't handle asset", "asset", a.File, "err", err) + } + errGroup = errors.Join(errGroup, err) } // Manage groups diff --git a/app/upload/upload.go b/app/upload/upload.go index ede80ed51..ce9c89c29 100644 --- a/app/upload/upload.go +++ b/app/upload/upload.go @@ -10,6 +10,7 @@ import ( "github.com/simulot/immich-go/adapters/fromimmich" gp "github.com/simulot/immich-go/adapters/googlePhotos" "github.com/simulot/immich-go/adapters/shared" + "github.com/simulot/immich-go/adapters/synology" "github.com/simulot/immich-go/app" "github.com/simulot/immich-go/immich" "github.com/simulot/immich-go/internal/assets" @@ -112,6 +113,7 @@ func NewUploadCommand(ctx context.Context, app *app.Application) *cobra.Command cmd.AddCommand(folder.NewFromPicasaCommand(ctx, cmd, app, uc)) cmd.AddCommand(gp.NewFromGooglePhotosCommand(ctx, cmd, app, uc)) cmd.AddCommand(fromimmich.NewFromImmichCommand(ctx, cmd, app, uc)) + cmd.AddCommand(synology.NewFromSynologyCommand(ctx, cmd, app, uc)) cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { // Initialize the FileProcessor (tracker + logger + event bus) diff --git a/go.mod b/go.mod index f7eb0bf76..67faf9bf7 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/go.sum b/go.sum index 2220521b5..1cdc74bf3 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= +github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= diff --git a/internal/assets/asset.go b/internal/assets/asset.go index cfb8c67ac..54c185ea0 100644 --- a/internal/assets/asset.go +++ b/internal/assets/asset.go @@ -36,6 +36,7 @@ const ( VisibilityUnknown Visibility = "" ) +type CloseOption func(*Asset) error type Asset struct { // File system and file name File fshelper.FSAndName @@ -72,6 +73,8 @@ type Asset struct { // buffer management cacheReader *cachereader.CacheReader + + closeOptions []CloseOption } // Kind is the probable type of the image @@ -99,6 +102,14 @@ type NameInfo struct { IsModified bool // is this is a modified version of the original } +func (a *Asset) AddCloseOption(o CloseOption) { + if a.closeOptions == nil { + a.closeOptions = []CloseOption{o} + return + } + a.closeOptions = append(a.closeOptions, o) +} + func (a *Asset) SetNameInfo(ni NameInfo) { a.NameInfo = ni } @@ -186,7 +197,7 @@ func (a *Asset) GetChecksum() (string, error) { return "", errors.New("no file to compute checksum") } - f, err := a.File.Open() + f, err := a.OpenFile() if err != nil { return "", err } diff --git a/internal/assets/assetFile.go b/internal/assets/assetFile.go index 06fa9f44c..6ced5f7e7 100644 --- a/internal/assets/assetFile.go +++ b/internal/assets/assetFile.go @@ -36,10 +36,17 @@ func (a *Asset) OpenFile() (osfs.OSFS, error) { // Close close the temporary file and close the source func (a *Asset) Close() error { - if a.cacheReader == nil { - return nil + if a.cacheReader != nil { + if err := a.cacheReader.Close(); err != nil { + return err + } + } + for _, o := range a.closeOptions { + if err := o(a); err != nil { + return err + } } - return a.cacheReader.Close() + return nil } /* diff --git a/internal/assets/asset_close_test.go b/internal/assets/asset_close_test.go new file mode 100644 index 000000000..e2894ec42 --- /dev/null +++ b/internal/assets/asset_close_test.go @@ -0,0 +1,57 @@ +package assets + +import ( + "errors" + "testing" +) + +// TestClose_NilCacheReader verifies that Close does not panic when the asset +// has never been opened (cacheReader is nil), which was the bug fixed by +// inverting the nil check in assetFile.go. +func TestClose_NilCacheReader(t *testing.T) { + a := &Asset{} + if err := a.Close(); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +// TestClose_RunsAllCloseOptions verifies that every registered CloseOption is +// called, and that the results are propagated correctly. +func TestClose_RunsAllCloseOptions(t *testing.T) { + called := make([]bool, 3) + a := &Asset{} + for i := range called { + i := i + a.AddCloseOption(func(_ *Asset) error { + called[i] = true + return nil + }) + } + + if err := a.Close(); err != nil { + t.Fatalf("expected no error, got %v", err) + } + for i, c := range called { + if !c { + t.Errorf("closeOption[%d] was not called", i) + } + } +} + +// TestClose_StopsOnFirstCloseOptionError verifies that the first error from a +// CloseOption is returned immediately. +func TestClose_StopsOnFirstCloseOptionError(t *testing.T) { + sentinel := errors.New("close failed") + secondCalled := false + a := &Asset{} + a.AddCloseOption(func(_ *Asset) error { return sentinel }) + a.AddCloseOption(func(_ *Asset) error { secondCalled = true; return nil }) + + err := a.Close() + if !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel error, got %v", err) + } + if secondCalled { + t.Error("second CloseOption should not have been called after the first returned an error") + } +} diff --git a/readme.md b/readme.md index 518776e01..b2861ee50 100644 --- a/readme.md +++ b/readme.md @@ -8,7 +8,7 @@ ## 🌟 Key Features - **Simple Installation**: No NodeJS or Docker required -- **Multiple Sources**: Upload from Google Photos Takeouts, iCloud, local folders, ZIP archives, and other Immich servers +- **Multiple Sources**: Upload from Google Photos Takeouts, iCloud, local folders, ZIP archives, Synology Photos, and other Immich servers - **Large Collections**: Successfully handles 100,000+ photos - **Smart Management**: Duplicate detection, burst photo stacking, RAW+JPEG handling - **Cross-Platform**: Available for Windows, macOS, Linux, and FreeBSD @@ -27,7 +27,11 @@ immich-go upload from-folder --server=http://your-ip:2283 --api-key=your-api-key immich-go upload from-google-photos --server=http://your-ip:2283 --api-key=your-api-key /path/to/takeout-*.zip # Archive photos from Immich server -immich-go archive from-immich --server=http://your-ip:2283 --api-key=your-api-key --write-to-folder=/path/to/archive +immich-go archive from-immich --from-server=http://your-ip:2283 --from-api-key=your-api-key --write-to-folder=/path/to/archive + +# Migrate from Synology Photos +immich-go upload from-synology --server=http://your-ip:2283 --api-key=your-api-key \ + --synology-url=https://nas:5001 --synology-user=admin --synology-pass=secret ``` ### 3. Requirements @@ -66,6 +70,7 @@ Here's a brief overview of the main upload commands: * **`from-immich`**: A server-to-server migration tool that allows you to copy assets between two Immich instances with fine-grained filtering. * **`from-picasa`**: A specialized version of `from-folder` that automatically reads `.picasa.ini` files to restore your Picasa album organization. * **`from-icloud`**: Another specialized command that handles the complexity of an iCloud Photos takeout, correctly identifying creation dates and album structures from the included CSV files. +* **`from-synology`**: Migrate from Synology Photos. Imports photos, videos, albums, tags, and face recognition data (converted to tags) from your Synology NAS. ### Leveraging Immich's Features @@ -82,6 +87,7 @@ For a detailed explanation of how each upload command works, please see the [Upl - **Google Photos Migration**: [Complete guide](docs/best-practices.md#google-photos-migration) - **iCloud Import**: [Step-by-step instructions](docs/examples.md#icloud-import) +- **Synology Photos Migration**: Import from Synology Photos with albums, tags, and face recognition preserved - **Server Migration**: [Transfer between Immich instances](docs/examples.md#server-migration) - **Bulk Organization**: [Stacking and tagging strategies](docs/best-practices.md#organization-strategies)