diff --git a/adapters/snapchat/cmdFromSnapchat.go b/adapters/snapchat/cmdFromSnapchat.go new file mode 100644 index 000000000..4d345d481 --- /dev/null +++ b/adapters/snapchat/cmdFromSnapchat.go @@ -0,0 +1,125 @@ +package snapchat + +import ( + "context" + "errors" + "io/fs" + "path/filepath" + "strings" + "time" + + "github.com/simulot/immich-go/adapters" + "github.com/simulot/immich-go/adapters/shared" + "github.com/simulot/immich-go/app" + "github.com/simulot/immich-go/internal/assets" + cliflags "github.com/simulot/immich-go/internal/cliFlags" + "github.com/simulot/immich-go/internal/filenames" + "github.com/simulot/immich-go/internal/fileprocessor" + "github.com/simulot/immich-go/internal/filetypes" + "github.com/simulot/immich-go/internal/filters" + "github.com/simulot/immich-go/internal/fshelper" + "github.com/simulot/immich-go/internal/groups" + "github.com/simulot/immich-go/internal/groups/burst" + "github.com/simulot/immich-go/internal/groups/epsonfastfoto" + "github.com/simulot/immich-go/internal/groups/series" + "github.com/simulot/immich-go/internal/namematcher" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type Command struct { + InclusionFlags cliflags.InclusionFlags + BannedFiles namematcher.List + shared.StackOptions + + app *app.Application + processor *fileprocessor.FileProcessor + tz *time.Location + infoCollector *filenames.InfoCollector + supportedMedia filetypes.SupportedMedia + fsyss []fs.FS + groupers []groups.Grouper +} + +func (sc *Command) RegisterFlags(flags *pflag.FlagSet, cmd *cobra.Command) { + sc.BannedFiles, _ = namematcher.New(shared.DefaultBannedFiles...) + flags.Var(&sc.BannedFiles, "ban-file", "Exclude a file based on a pattern (case-insensitive). Can be specified multiple times.") + sc.InclusionFlags.RegisterFlags(flags, "") + if cmd.Parent() != nil && cmd.Parent().Name() == "upload" { + sc.StackOptions.RegisterFlags(flags) + } +} + +func NewFromSnapchatCommand(ctx context.Context, _ *cobra.Command, app *app.Application, runner adapters.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "from-snapchat [flags] | ", + Short: "Upload photos and videos from a Snapchat memories export", + Args: cobra.MinimumNArgs(1), + } + cmd.SetContext(ctx) + + sc := &Command{app: app} + sc.RegisterFlags(cmd.Flags(), cmd) + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + var err error + + sc.processor = app.FileProcessor() + sc.tz = app.GetTZ() + sc.supportedMedia = app.GetSupportedMedia() + + sc.fsyss, err = fshelper.ParsePath(args) + if err != nil { + return err + } + if len(sc.fsyss) == 0 { + app.Log().Message("No file found matching the pattern: %s", strings.Join(args, ",")) + return errors.New("No file found matching the pattern: " + strings.Join(args, ",")) + } + + defer func() { + if err := fshelper.CloseFSs(sc.fsyss); err != nil { + app.Log().Error("error closing file systems", "error", err) + } + }() + + if sc.InclusionFlags.DateRange.IsSet() { + sc.InclusionFlags.DateRange.SetTZ(sc.tz) + } + + if sc.ManageEpsonFastFoto { + sc.groupers = append(sc.groupers, epsonfastfoto.Group{}.Group) + } + if sc.ManageBurst != filters.BurstNothing { + sc.groupers = append(sc.groupers, burst.Group) + } + sc.groupers = append(sc.groupers, series.Group) + + sc.infoCollector = filenames.NewInfoCollector(sc.tz, sc.supportedMedia) + + return runner.Run(cmd, sc) + } + + return cmd +} + +func (sc *Command) makeAssetFromMediaFile(file mediaFile) *assets.Asset { + a := &assets.Asset{ + File: fshelper.FSName(file.fsys, file.name), + OriginalFileName: file.base, + FileDate: file.modTime, + FileSize: int(file.size), + } + a.SetNameInfo(sc.infoCollector.GetInfo(a.OriginalFileName)) + if file.metadata != nil { + a.FromApplication = a.UseMetadata(file.metadata) + } + if file.overlay != nil { + ext := strings.ToLower(filepath.Ext(a.OriginalFileName)) + if file.mainType() == mediaTypeImage && ext != ".jpg" && ext != ".jpeg" && ext != ".png" { + a.OriginalFileName = strings.TrimSuffix(a.OriginalFileName, filepath.Ext(a.OriginalFileName)) + ".jpg" + } + a.File = fshelper.FSName(newMergedFS(file, sc.app), mergedAssetName) + } + return a +} diff --git a/adapters/snapchat/mergedfs.go b/adapters/snapchat/mergedfs.go new file mode 100644 index 000000000..cab6a0548 --- /dev/null +++ b/adapters/snapchat/mergedfs.go @@ -0,0 +1,237 @@ +package snapchat + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + "image/png" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/disintegration/imaging" + "github.com/simulot/immich-go/app" + "github.com/simulot/immich-go/internal/fshelper" + "github.com/simulot/immich-go/internal/fshelper/debugfiles" +) + +type mergedFS struct { + media mediaFile + app *app.Application + name string +} + +func newMergedFS(media mediaFile, app *app.Application) fs.FS { + full := fshelper.FSName(media.main.fsys, media.main.name).FullName() + return &mergedFS{ + media: media, + app: app, + name: "snapchat-merged:" + full, + } +} + +func (m *mergedFS) Open(name string) (fs.File, error) { + if name != mergedAssetName { + return nil, fs.ErrNotExist + } + mainFile, err := m.media.main.fsys.Open(m.media.main.name) + if err != nil { + return nil, err + } + debugfiles.TrackOpenFile(mainFile, m.media.main.name) + + ext := strings.ToLower(filepath.Ext(m.media.main.base)) + typeMain := m.media.mainType() + if m.media.overlay == nil { + return mainFile, nil + } + + overlayFile, err := m.media.overlay.fsys.Open(m.media.overlay.name) + if err != nil { + m.app.Log().Warn("can't open overlay, using main media", "main", m.media.main.name, "overlay", m.media.overlay.name, "err", err) + return mainFile, nil + } + debugfiles.TrackOpenFile(overlayFile, m.media.overlay.name) + + temp, err := os.CreateTemp("", "immich-go-snapchat-merged-*") + if err != nil { + _ = overlayFile.Close() + return nil, err + } + debugfiles.TrackOpenFile(temp, temp.Name()) + + switch typeMain { + case mediaTypeImage: + err = mergeImage(mainFile, overlayFile, temp, ext) + case mediaTypeVideo: + err = mergeVideo(mainFile, overlayFile, temp) + default: + err = fmt.Errorf("unsupported media type for overlay merge") + } + + _ = overlayFile.Close() + if err != nil { + _ = temp.Close() + _ = os.Remove(temp.Name()) + m.app.Log().Warn("can't merge overlay, using main media", "main", m.media.main.name, "overlay", m.media.overlay.name, "err", err) + return mainFile, nil + } + + _, err = temp.Seek(0, io.SeekStart) + if err != nil { + _ = temp.Close() + _ = os.Remove(temp.Name()) + return nil, err + } + + _ = mainFile.Close() + return &tempMergedFile{File: temp}, nil +} + +type tempMergedFile struct { + *os.File +} + +func (f *tempMergedFile) Close() error { + name := f.Name() + err := f.File.Close() + debugfiles.TrackCloseFile(f.File) + return errors.Join(err, os.Remove(name)) +} + +func mergeImage(main io.Reader, overlay io.Reader, out io.Writer, ext string) error { + base, _, err := image.Decode(main) + if err != nil { + return err + } + over, _, err := image.Decode(overlay) + if err != nil { + return err + } + over = imaging.Resize(over, base.Bounds().Dx(), base.Bounds().Dy(), imaging.Lanczos) + merged := imaging.Overlay(base, over, image.Point{X: 0, Y: 0}, 1.0) + if ext == ".png" { + return png.Encode(out, merged) + } + return imaging.Encode(out, merged, imaging.JPEG) +} + +func mergeVideo(main io.Reader, overlay io.Reader, out *os.File) error { + if _, err := exec.LookPath("ffmpeg"); err != nil { + return fmt.Errorf("ffmpeg not found: %w", err) + } + + mainTmp, err := os.CreateTemp("", "immich-go-snapchat-main-*.mp4") + if err != nil { + return err + } + defer os.Remove(mainTmp.Name()) + defer mainTmp.Close() + if _, err := io.Copy(mainTmp, main); err != nil { + return err + } + + overlayTmp, err := os.CreateTemp("", "immich-go-snapchat-overlay-*.png") + if err != nil { + return err + } + defer os.Remove(overlayTmp.Name()) + defer overlayTmp.Close() + if _, err := io.Copy(overlayTmp, overlay); err != nil { + return err + } + + outTmp, err := os.CreateTemp("", "immich-go-snapchat-video-merged-*.mp4") + if err != nil { + return err + } + outPath := outTmp.Name() + _ = outTmp.Close() + defer os.Remove(outPath) + + cmd := exec.CommandContext(context.Background(), "ffmpeg", "-y", "-loglevel", "error", "-i", mainTmp.Name(), "-i", overlayTmp.Name(), "-filter_complex", "[1:v][0:v]scale2ref=w=iw:h=ih[ovr][vid];[vid][ovr]overlay=0:0:eof_action=repeat[v]", "-map", "0:a?", "-map", "[v]", "-c:v", "libx264", "-c:a", "copy", outPath) + cmd.Stderr = &bytes.Buffer{} + cmd.Stdout = io.Discard + + if err := cmd.Run(); err != nil { + if b, ok := cmd.Stderr.(*bytes.Buffer); ok { + return fmt.Errorf("ffmpeg merge failed: %w: %s", err, strings.TrimSpace(b.String())) + } + return err + } + + if _, err := out.Seek(0, io.SeekStart); err != nil { + return err + } + if err := out.Truncate(0); err != nil { + return err + } + f, err := os.Open(outPath) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(out, f) + if err != nil { + return err + } + _, err = out.Seek(0, io.SeekStart) + return err +} + +type mediaKind int + +const ( + mediaTypeUnknown mediaKind = iota + mediaTypeImage + mediaTypeVideo +) + +func (m mediaFile) mainType() mediaKind { + ext := strings.ToLower(filepath.Ext(m.main.base)) + t := fileType(ext) + switch t { + case "image": + return mediaTypeImage + case "video": + return mediaTypeVideo + default: + return mediaTypeUnknown + } +} + +func fileType(ext string) string { + supported := map[string]string{ + ".jpg": "image", ".jpeg": "image", ".png": "image", ".webp": "image", ".heic": "image", ".heif": "image", + ".mp4": "video", ".mov": "video", ".m4v": "video", ".webm": "video", + } + if t, ok := supported[ext]; ok { + return t + } + return "" +} + +func (m *mergedFS) Stat(name string) (fs.FileInfo, error) { + if name != mergedAssetName { + return nil, fs.ErrNotExist + } + if m.media.main == nil { + return nil, fs.ErrNotExist + } + return fshelper.Stat(m.media.main.fsys, m.media.main.name) +} + +func (m *mergedFS) Name() string { + return m.name +} + +var _ fs.FS = (*mergedFS)(nil) +var _ fshelper.FSCanStat = (*mergedFS)(nil) +var _ fshelper.NameFS = (*mergedFS)(nil) diff --git a/adapters/snapchat/parser.go b/adapters/snapchat/parser.go new file mode 100644 index 000000000..9cda067bd --- /dev/null +++ b/adapters/snapchat/parser.go @@ -0,0 +1,162 @@ +package snapchat + +import ( + "io/fs" + "net/url" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/simulot/immich-go/internal/assets" + "github.com/simulot/immich-go/internal/fshelper" +) + +var ( + reMainFile = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}_(.+)-main\.[A-Za-z0-9]+$`) + reOverlayFile = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}_(.+)-overlay\.png$`) + reLocation = regexp.MustCompile(`^Latitude, Longitude:\s*([-+]?\d+(?:\.\d+)?)\s*,\s*([-+]?\d+(?:\.\d+)?)$`) +) + +type memoriesHistory struct { + SavedMedia []savedMedia `json:"Saved Media"` +} + +type savedMedia struct { + Date string `json:"Date"` + MediaType string `json:"Media Type"` + Location string `json:"Location"` + DownloadLink string `json:"Download Link"` + MediaDownloadURL string `json:"Media Download Url"` +} + +func readMemoriesHistory(fsys fs.FS, name string) (map[string]*assets.Metadata, error) { + r, err := fshelper.ReadJSON[memoriesHistory](fsys, name) + if err != nil { + return nil, err + } + out := map[string]*assets.Metadata{} + for _, item := range r.SavedMedia { + sid := extractID(item.DownloadLink, "sid") + if sid == "" { + sid = extractID(item.MediaDownloadURL, "sid") + } + mid := extractID(item.DownloadLink, "mid") + if mid == "" { + mid = extractID(item.MediaDownloadURL, "mid") + } + if sid == "" && mid == "" { + continue + } + md := &assets.Metadata{} + md.DateTaken = parseSnapDate(item.Date) + if lat, lon, ok := parseLocation(item.Location); ok { + md.Latitude = lat + md.Longitude = lon + } + + if sid != "" { + upsertMetadata(out, normalizeSID(sid), md) + } + if mid != "" { + upsertMetadata(out, normalizeSID(mid), md) + } + } + return out, nil +} + +func upsertMetadata(out map[string]*assets.Metadata, key string, md *assets.Metadata) { + if key == "" || md == nil { + return + } + if existing, ok := out[key]; ok { + if existing.DateTaken.IsZero() && !md.DateTaken.IsZero() { + existing.DateTaken = md.DateTaken + } + if existing.Latitude == 0 && existing.Longitude == 0 && (md.Latitude != 0 || md.Longitude != 0) { + existing.Latitude = md.Latitude + existing.Longitude = md.Longitude + } + return + } + out[key] = &assets.Metadata{ + DateTaken: md.DateTaken, + Latitude: md.Latitude, + Longitude: md.Longitude, + FileName: md.FileName, + Description: md.Description, + } +} + +func isSnapMemoriesPath(name string) bool { + if !strings.HasPrefix(name, "memories/") { + return false + } + base := path.Base(name) + return strings.Contains(base, "-main.") || strings.HasSuffix(strings.ToLower(base), "-overlay.png") +} + +func parseMainSID(base string) (string, bool) { + m := reMainFile.FindStringSubmatch(base) + if len(m) != 2 { + return "", false + } + return m[1], true +} + +func parseOverlaySID(base string) (string, bool) { + m := reOverlayFile.FindStringSubmatch(base) + if len(m) != 2 { + return "", false + } + return m[1], true +} + +func normalizeSID(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +func parseSnapDate(s string) time.Time { + t, err := time.Parse("2006-01-02 15:04:05 MST", strings.TrimSpace(s)) + if err != nil { + return time.Time{} + } + return t +} + +func inferDateFromSnapName(base string) time.Time { + if len(base) < 10 { + return time.Time{} + } + date := base[:10] + t, err := time.ParseInLocation("2006-01-02", date, time.UTC) + if err != nil { + return time.Time{} + } + return t +} + +func parseLocation(s string) (float64, float64, bool) { + m := reLocation.FindStringSubmatch(strings.TrimSpace(s)) + if len(m) != 3 { + return 0, 0, false + } + lat, err := strconv.ParseFloat(m[1], 64) + if err != nil { + return 0, 0, false + } + lon, err := strconv.ParseFloat(m[2], 64) + if err != nil { + return 0, 0, false + } + return lat, lon, true +} + +func extractID(rawURL string, key string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + return u.Query().Get(key) +} diff --git a/adapters/snapchat/parser_test.go b/adapters/snapchat/parser_test.go new file mode 100644 index 000000000..7770c5c59 --- /dev/null +++ b/adapters/snapchat/parser_test.go @@ -0,0 +1,64 @@ +package snapchat + +import ( + "testing/fstest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseMainSID(t *testing.T) { + sid, ok := parseMainSID("2026-04-07_4E294D9B-4B1D-4629-A838-5A8D68ECDF7B-main.jpg") + require.True(t, ok) + require.Equal(t, "4E294D9B-4B1D-4629-A838-5A8D68ECDF7B", sid) +} + +func TestParseOverlaySID(t *testing.T) { + sid, ok := parseOverlaySID("2026-04-07_4E294D9B-4B1D-4629-A838-5A8D68ECDF7B-overlay.png") + require.True(t, ok) + require.Equal(t, "4E294D9B-4B1D-4629-A838-5A8D68ECDF7B", sid) +} + +func TestExtractID(t *testing.T) { + u := "https://app.snapchat.com/dmd/memories?uid=x&sid=ABC-123&mid=DEF-456" + require.Equal(t, "ABC-123", extractID(u, "sid")) + require.Equal(t, "DEF-456", extractID(u, "mid")) +} + +func TestParseLocation(t *testing.T) { + lat, lon, ok := parseLocation("Latitude, Longitude: 55.700417, 13.202691") + require.True(t, ok) + require.InDelta(t, 55.700417, lat, 0.000001) + require.InDelta(t, 13.202691, lon, 0.000001) +} + +func TestParseSnapDate(t *testing.T) { + d := parseSnapDate("2026-04-07 18:40:45 UTC") + require.False(t, d.IsZero()) + require.Equal(t, 2026, d.Year()) + require.Equal(t, 4, int(d.Month())) + require.Equal(t, 7, d.Day()) +} + +func TestReadMemoriesHistoryMapsBothSIDAndMID(t *testing.T) { + json := `{ + "Saved Media": [ + { + "Date": "2020-12-08 13:51:46 UTC", + "Location": "Latitude, Longitude: 55.700417, 13.202691", + "Download Link": "https://app.snapchat.com/dmd/memories?sid=lower-sid&mid=MID-ID" + } + ] + }` + fsys := fstest.MapFS{ + "json/memories_history.json": &fstest.MapFile{Data: []byte(json)}, + } + + m, err := readMemoriesHistory(fsys, "json/memories_history.json") + require.NoError(t, err) + require.Contains(t, m, "lower-sid") + require.Contains(t, m, "mid-id") + require.Equal(t, m["lower-sid"].DateTaken, m["mid-id"].DateTaken) + require.InDelta(t, 55.700417, m["mid-id"].Latitude, 0.000001) + require.InDelta(t, 13.202691, m["mid-id"].Longitude, 0.000001) +} diff --git a/adapters/snapchat/snapchat.go b/adapters/snapchat/snapchat.go new file mode 100644 index 000000000..36a67262a --- /dev/null +++ b/adapters/snapchat/snapchat.go @@ -0,0 +1,193 @@ +package snapchat + +import ( + "context" + "io/fs" + "path" + "path/filepath" + "strings" + + "github.com/simulot/immich-go/internal/assets" + "github.com/simulot/immich-go/internal/fileevent" + "github.com/simulot/immich-go/internal/filetypes" + "github.com/simulot/immich-go/internal/fshelper" + "github.com/simulot/immich-go/internal/groups" +) + +func (sc *Command) Browse(ctx context.Context) chan *assets.Group { + out := make(chan *assets.Group) + go func() { + defer close(out) + + catalog, err := sc.passOne(ctx) + if err = sc.app.ProcessError(err); err != nil { + return + } + + err = sc.passTwo(ctx, catalog, out) + if err = sc.app.ProcessError(err); err != nil { + return + } + }() + return out +} + +func (sc *Command) passOne(ctx context.Context) (*catalog, error) { + cat := newCatalog() + for _, w := range sc.fsyss { + err := fs.WalkDir(w, ".", func(name string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if sc.BannedFiles.Match(name) { + sc.processor.RecordNonAsset(ctx, fshelper.FSName(w, name), 0, fileevent.DiscoveredBanned, "reason", "banned file") + return nil + } + + if strings.EqualFold(name, memoriesHistoryJSON) || strings.HasSuffix(strings.ToLower(name), "/"+memoriesHistoryJSON) { + m, err := readMemoriesHistory(w, name) + if err != nil { + sc.processor.RecordNonAsset(ctx, fshelper.FSName(w, name), 0, fileevent.ErrorFileAccess, "error", err.Error()) + return nil + } + cat.addMetadata(m) + sc.processor.RecordNonAsset(ctx, fshelper.FSName(w, name), 0, fileevent.DiscoveredMetadata, "type", "snapchat memories history") + return nil + } + + if !isSnapMemoriesPath(name) { + if sc.supportedMedia.IsUseLess(name) { + sc.processor.RecordNonAsset(ctx, fshelper.FSName(w, name), 0, fileevent.DiscoveredUnknown, "reason", "useless file") + return nil + } + if sc.supportedMedia.TypeFromExt(strings.ToLower(path.Ext(name))) == filetypes.TypeUnknown { + sc.processor.RecordNonAsset(ctx, fshelper.FSName(w, name), 0, fileevent.DiscoveredUnsupported, "reason", "unsupported file type") + return nil + } + return nil + } + + info, err := fs.Stat(w, name) + if err != nil { + sc.processor.RecordNonAsset(ctx, fshelper.FSName(w, name), 0, fileevent.ErrorFileAccess, "error", err.Error()) + return nil + } + + base := path.Base(name) + if sid, ok := parseOverlaySID(base); ok { + cat.setOverlay(normalizeSID(sid), &fileRef{fsys: w, name: name, base: base, size: info.Size(), modTime: info.ModTime()}) + sc.processor.RecordNonAsset(ctx, fshelper.FSName(w, name), info.Size(), fileevent.DiscoveredSidecar, "type", "snapchat overlay") + return nil + } + + sid, ok := parseMainSID(base) + if !ok { + sc.processor.RecordNonAsset(ctx, fshelper.FSName(w, name), info.Size(), fileevent.DiscoveredUnsupported, "reason", "unsupported snapchat memories filename") + return nil + } + + ext := strings.ToLower(filepath.Ext(base)) + if !sc.InclusionFlags.IncludedExtensions.Include(ext) { + sc.processor.RecordAssetDiscardedImmediately(ctx, fshelper.FSName(w, name), info.Size(), fileevent.DiscardedFiltered, "extension not included") + return nil + } + if sc.InclusionFlags.ExcludedExtensions.Exclude(ext) { + sc.processor.RecordAssetDiscardedImmediately(ctx, fshelper.FSName(w, name), info.Size(), fileevent.DiscardedFiltered, "extension excluded") + return nil + } + + cat.setMain(normalizeSID(sid), &fileRef{fsys: w, name: name, base: base, size: info.Size(), modTime: info.ModTime()}) + return nil + }) + if err != nil { + return nil, err + } + } + + if cat.metadataCount == 0 { + sc.app.Log().Warn("can't find json/memories_history.json in input") + } + + return cat, nil +} + +func (sc *Command) passTwo(ctx context.Context, cat *catalog, out chan *assets.Group) error { + entries := cat.sortedEntries() + in := make(chan *assets.Asset) + go func() { + defer close(in) + for _, e := range entries { + if e.main == nil { + if e.overlay != nil { + sc.app.Log().Warn("overlay without matching main media", "file", e.overlay.name) + } + continue + } + + asset := sc.makeAssetFromMediaFile(sc.makeMediaFile(e)) + code := fileevent.DiscoveredImage + if sc.supportedMedia.TypeFromExt(strings.ToLower(filepath.Ext(e.main.base))) == filetypes.TypeVideo { + code = fileevent.DiscoveredVideo + } + sc.processor.RecordAssetDiscovered(ctx, asset.File, int64(asset.FileSize), code) + if sc.InclusionFlags.DateRange.IsSet() && !sc.InclusionFlags.DateRange.InRange(asset.CaptureDate) { + asset.Close() + sc.processor.RecordAssetDiscardedImmediately(ctx, asset.File, int64(asset.FileSize), fileevent.DiscardedFiltered, "asset outside date range") + continue + } + + select { + case in <- asset: + case <-ctx.Done(): + return + } + } + }() + + gs := groups.NewGrouperPipeline(ctx, sc.groupers...).PipeGrouper(ctx, in) + for g := range gs { + select { + case out <- g: + case <-ctx.Done(): + return ctx.Err() + } + } + + return nil +} + +func (sc *Command) makeMediaFile(e *catalogEntry) mediaFile { + m := mediaFile{} + m.sid = e.sid + m.fsys = e.main.fsys + m.name = e.main.name + m.base = e.main.base + m.size = e.main.size + m.modTime = e.main.modTime + m.main = e.main + m.overlay = e.overlay + m.metadata = e.metadata + + if m.metadata == nil { + m.metadata = &assets.Metadata{} + } + if m.metadata.FileName == "" { + m.metadata.FileName = e.main.base + } + if m.metadata.DateTaken.IsZero() { + m.metadata.DateTaken = inferDateFromSnapName(e.main.base) + } + if m.metadata.DateTaken.IsZero() { + m.metadata.DateTaken = e.main.modTime + } + return m +} diff --git a/adapters/snapchat/types.go b/adapters/snapchat/types.go new file mode 100644 index 000000000..b8e81c337 --- /dev/null +++ b/adapters/snapchat/types.go @@ -0,0 +1,101 @@ +package snapchat + +import ( + "io/fs" + "sort" + "time" + + "github.com/simulot/immich-go/internal/assets" +) + +const ( + memoriesHistoryJSON = "json/memories_history.json" + mergedAssetName = "merged" +) + +type fileRef struct { + fsys fs.FS + name string + base string + size int64 + modTime time.Time +} + +type catalogEntry struct { + sid string + main *fileRef + overlay *fileRef + metadata *assets.Metadata +} + +type catalog struct { + entries map[string]*catalogEntry + metadataCount int +} + +func newCatalog() *catalog { + return &catalog{entries: map[string]*catalogEntry{}} +} + +func (c *catalog) getOrCreate(sid string) *catalogEntry { + e, ok := c.entries[sid] + if ok { + return e + } + e = &catalogEntry{sid: sid} + c.entries[sid] = e + return e +} + +func (c *catalog) setMain(sid string, ref *fileRef) { + e := c.getOrCreate(sid) + if e.main == nil { + e.main = ref + return + } + if ref.size > e.main.size { + e.main = ref + } +} + +func (c *catalog) setOverlay(sid string, ref *fileRef) { + e := c.getOrCreate(sid) + if e.overlay == nil { + e.overlay = ref + return + } + if ref.size > e.overlay.size { + e.overlay = ref + } +} + +func (c *catalog) addMetadata(m map[string]*assets.Metadata) { + for sid, md := range m { + e := c.getOrCreate(sid) + e.metadata = md + } + c.metadataCount += len(m) +} + +func (c *catalog) sortedEntries() []*catalogEntry { + out := make([]*catalogEntry, 0, len(c.entries)) + for _, e := range c.entries { + out = append(out, e) + } + sort.Slice(out, func(i, j int) bool { + return out[i].sid < out[j].sid + }) + return out +} + +type mediaFile struct { + sid string + fsys fs.FS + name string + base string + size int64 + modTime time.Time + main *fileRef + overlay *fileRef + metadata *assets.Metadata +} diff --git a/app/archive/archiveCmd.go b/app/archive/archiveCmd.go index 70f7d51f3..764bccce2 100644 --- a/app/archive/archiveCmd.go +++ b/app/archive/archiveCmd.go @@ -7,6 +7,7 @@ import ( "github.com/simulot/immich-go/adapters/folder" "github.com/simulot/immich-go/adapters/fromimmich" gp "github.com/simulot/immich-go/adapters/googlePhotos" + "github.com/simulot/immich-go/adapters/snapchat" "github.com/simulot/immich-go/app" "github.com/simulot/immich-go/internal/assettracker" "github.com/simulot/immich-go/internal/fileevent" @@ -38,6 +39,7 @@ func NewArchiveCommand(ctx context.Context, app *app.Application) *cobra.Command cmd.AddCommand(folder.NewFromPicasaCommand(ctx, cmd, app, ac)) cmd.AddCommand(fromimmich.NewFromImmichCommand(ctx, cmd, app, ac)) cmd.AddCommand(gp.NewFromGooglePhotosCommand(ctx, cmd, app, ac)) + cmd.AddCommand(snapchat.NewFromSnapchatCommand(ctx, cmd, app, ac)) cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { // Initialize the FileProcessor (tracker + logger) diff --git a/app/upload/advice.go b/app/upload/advice.go index d0d12e2d2..7dddf558c 100644 --- a/app/upload/advice.go +++ b/app/upload/advice.go @@ -4,6 +4,9 @@ import ( "fmt" "math" "path" + "regexp" + "sort" + "strings" "sync" "sync/atomic" "time" @@ -64,6 +67,12 @@ type immichIndex struct { // map of SHA1 to assetID byChecksum *syncmap.SyncMap[string, *assets.Asset] + // map of conservative duplicate keys to assets + byConservative *syncmap.SyncMap[string, []*assets.Asset] + + // map of day+type to assets (used for Snapchat fuzzy duplicate matching) + byDayType *syncmap.SyncMap[string, []*assets.Asset] + assetNumber int64 } @@ -71,6 +80,8 @@ func newAssetIndex() *immichIndex { return &immichIndex{ immichAssets: syncmap.New[string, *assets.Asset](), byChecksum: syncmap.New[string, *assets.Asset](), + byConservative: syncmap.New[string, []*assets.Asset](), + byDayType: syncmap.New[string, []*assets.Asset](), byName: syncmap.New[string, []string](), uploadsChecksum: syncset.New[string](), } @@ -144,6 +155,18 @@ func (ii *immichIndex) add(a *assets.Asset, local bool) *assets.Asset { l, _ := ii.byName.Load(filename) l = append(l, a.ID) ii.byName.Store(filename, l) + + if key := conservativeKey(a); key != "" { + l2, _ := ii.byConservative.Load(key) + l2 = append(l2, a) + ii.byConservative.Store(key, l2) + } + + if key := dayTypeKey(a); key != "" { + l3, _ := ii.byDayType.Load(key) + l3 = append(l3, a) + ii.byDayType.Store(key, l3) + } return a } @@ -297,9 +320,283 @@ func (ii *immichIndex) ShouldUpload(la *assets.Asset, upCmd *UpCmd) (*Advice, er } } } + + if upCmd.ConservativeDuplicates { + for _, key := range conservativeCandidateKeys(la) { + candidates, ok := ii.byConservative.Load(key) + if !ok { + continue + } + for _, sa := range candidates { + if sa == nil { + continue + } + if upCmd.app != nil { + upCmd.app.Log().Debug("conservative duplicate matched", "local", la.OriginalFileName, "server", sa.OriginalFileName, "key", key) + } + return ii.adviceSameOnServer(sa), nil + } + } + + if best := ii.findSnapchatFuzzyDuplicate(la); best != nil { + if upCmd.app != nil { + upCmd.app.Log().Debug("snapchat fuzzy duplicate matched", "local", la.OriginalFileName, "server", best.OriginalFileName) + } + return ii.adviceSameOnServer(best), nil + } + } return ii.adviceNotOnServer(), nil } +var snapMainNameRE = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}_.+-main\.[A-Za-z0-9]+$`) + +func isSnapchatMainName(name string) bool { + return snapMainNameRE.MatchString(name) +} + +func isSnapchatImmichName(name string) bool { + return strings.HasPrefix(strings.ToLower(name), "snapchat-") +} + +func normalizeAssetType(a *assets.Asset) string { + if a == nil { + return "" + } + t := strings.ToLower(strings.TrimSpace(a.Type)) + if t != "" { + return t + } + ext := strings.ToLower(path.Ext(a.OriginalFileName)) + switch ext { + case ".jpg", ".jpeg", ".png", ".heic", ".heif", ".webp", ".gif": + return "image" + case ".mp4", ".mov", ".m4v", ".webm": + return "video" + default: + return "" + } +} + +func dayTypeKey(a *assets.Asset) string { + if a == nil { + return "" + } + t := a.CaptureDate + if t.IsZero() { + t = a.FileDate + } + if t.IsZero() { + return "" + } + k := normalizeAssetType(a) + if k == "" { + return "" + } + day := t.UTC().Format("2006-01-02") + return k + "|" + day +} + +func (ii *immichIndex) findSnapchatFuzzyDuplicate(la *assets.Asset) *assets.Asset { + if la == nil || !isSnapchatMainName(la.OriginalFileName) { + return nil + } + keys := dayTypeWindowKeys(la, 1) + if len(keys) == 0 { + return nil + } + + candidateByID := map[string]*assets.Asset{} + for _, key := range keys { + candidates, ok := ii.byDayType.Load(key) + if !ok || len(candidates) == 0 { + continue + } + for _, sa := range candidates { + if sa == nil || sa.ID == "" { + continue + } + candidateByID[sa.ID] = sa + } + } + if len(candidateByID) == 0 { + return nil + } + + localSize := int64(la.FileSize) + if localSize <= 0 { + return nil + } + localType := normalizeAssetType(la) + localDay := normalizeToDay(la.CaptureDate) + if localDay.IsZero() { + localDay = normalizeToDay(la.FileDate) + } + + bestScore := 2.0 + secondScore := 2.0 + var best *assets.Asset + typeBaseThreshold := 0.33 // images + if localType == "video" { + typeBaseThreshold = 0.90 + } + + candidates := make([]*assets.Asset, 0, len(candidateByID)) + for _, sa := range candidateByID { + candidates = append(candidates, sa) + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].ID < candidates[j].ID }) + + for _, sa := range candidates { + if sa == nil || sa.Trashed || !isSnapchatImmichName(sa.OriginalFileName) { + continue + } + if normalizeAssetType(sa) != localType { + continue + } + + serverDay := normalizeToDay(sa.CaptureDate) + if serverDay.IsZero() { + serverDay = normalizeToDay(sa.FileDate) + } + if serverDay.IsZero() || localDay.IsZero() { + continue + } + dayDiff := absDaysBetween(localDay, serverDay) + if dayDiff > 1 { + continue + } + + serverSize := int64(sa.FileSize) + if serverSize <= 0 { + continue + } + sizeScore := float64(absInt64(localSize-serverSize)) / float64(maxInt64(localSize, serverSize)) + score := sizeScore + float64(dayDiff)*0.08 + + threshold := typeBaseThreshold + if dayDiff == 1 { + threshold -= 0.08 + } + if sizeScore > threshold { + continue + } + + if score < bestScore { + secondScore = bestScore + bestScore = score + best = sa + } else if score < secondScore { + secondScore = score + } + } + + if best == nil { + return nil + } + + // Avoid ambiguous matches among many same-day assets. + if secondScore-bestScore < 0.05 { + return nil + } + + return best +} + +func dayTypeWindowKeys(a *assets.Asset, dayWindow int) []string { + if a == nil { + return nil + } + t := normalizeToDay(a.CaptureDate) + if t.IsZero() { + t = normalizeToDay(a.FileDate) + } + if t.IsZero() { + return nil + } + typeName := normalizeAssetType(a) + if typeName == "" { + return nil + } + keys := make([]string, 0, dayWindow*2+1) + for delta := -dayWindow; delta <= dayWindow; delta++ { + day := t.AddDate(0, 0, delta).Format("2006-01-02") + keys = append(keys, typeName+"|"+day) + } + return keys +} + +func normalizeToDay(t time.Time) time.Time { + if t.IsZero() { + return time.Time{} + } + u := t.UTC() + return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC) +} + +func absDaysBetween(a, b time.Time) int { + if a.IsZero() || b.IsZero() { + return 0 + } + d := a.Sub(b) + if d < 0 { + d = -d + } + return int(d / (24 * time.Hour)) +} + +func absInt64(x int64) int64 { + if x < 0 { + return -x + } + return x +} + +func maxInt64(a int64, b int64) int64 { + if a > b { + return a + } + return b +} + +func conservativeCandidateKeys(a *assets.Asset) []string { + if conservativeKey(a) == "" { + return nil + } + dateTaken := a.CaptureDate + if dateTaken.IsZero() { + dateTaken = a.FileDate + } + t := dateTaken.UTC() + size := a.FileSize + typeName := normalizeAssetType(a) + if typeName == "" { + typeName = "unknown" + } + keys := make([]string, 0, 11) + for delta := -5; delta <= 5; delta++ { + keys = append(keys, fmt.Sprintf("%s|%d|%d", typeName, size, t.Add(time.Duration(delta)*time.Second).Unix())) + } + return keys +} + +func conservativeKey(a *assets.Asset) string { + if a == nil { + return "" + } + dateTaken := a.CaptureDate + if dateTaken.IsZero() { + dateTaken = a.FileDate + } + if dateTaken.IsZero() { + return "" + } + typeName := normalizeAssetType(a) + if typeName == "" { + typeName = "unknown" + } + return fmt.Sprintf("%s|%d|%d", typeName, a.FileSize, dateTaken.UTC().Unix()) +} + func compareDate(d1 time.Time, d2 time.Time) int { diff := d1.Sub(d2) diff --git a/app/upload/advice_snapchat_test.go b/app/upload/advice_snapchat_test.go new file mode 100644 index 000000000..84fc9e43d --- /dev/null +++ b/app/upload/advice_snapchat_test.go @@ -0,0 +1,164 @@ +package upload + +import ( + "testing" + "time" + + "github.com/simulot/immich-go/internal/assets" + "github.com/stretchr/testify/require" +) + +func TestFindSnapchatFuzzyDuplicateImage(t *testing.T) { + ii := newAssetIndex() + day := time.Date(2019, 11, 1, 13, 34, 35, 0, time.UTC) + + server := &assets.Asset{ + ID: "s1", + OriginalFileName: "Snapchat-359293977.jpg", + CaptureDate: day, + FileSize: 232793, + Checksum: "c1", + NameInfo: assets.NameInfo{Type: "image"}, + } + ii.add(server, false) + + local := &assets.Asset{ + OriginalFileName: "2019-11-01_1097af19-9bd1-d7aa-9baa-bbdc4a311bce-main.jpg", + CaptureDate: time.Date(2019, 11, 1, 0, 0, 0, 0, time.UTC), + FileDate: day, + FileSize: 233218, + NameInfo: assets.NameInfo{Type: "image"}, + } + + best := ii.findSnapchatFuzzyDuplicate(local) + require.NotNil(t, best) + require.Equal(t, "s1", best.ID) +} + +func TestFindSnapchatFuzzyDuplicateVideo(t *testing.T) { + ii := newAssetIndex() + day := time.Date(2019, 11, 1, 19, 10, 9, 0, time.UTC) + + server := &assets.Asset{ + ID: "s1", + OriginalFileName: "Snapchat-35037201.mp4", + CaptureDate: day, + FileSize: 524977, + Checksum: "c1", + NameInfo: assets.NameInfo{Type: "video"}, + } + ii.add(server, false) + + local := &assets.Asset{ + OriginalFileName: "2019-11-01_3078fe42-7c55-4ed8-28d7-b0a5fae6b10d-main.mp4", + CaptureDate: time.Date(2019, 11, 1, 0, 0, 0, 0, time.UTC), + FileDate: day, + FileSize: 1312244, + NameInfo: assets.NameInfo{Type: "video"}, + } + + best := ii.findSnapchatFuzzyDuplicate(local) + require.NotNil(t, best) + require.Equal(t, "s1", best.ID) +} + +func TestFindSnapchatFuzzyDuplicateNotSnapchatLocalName(t *testing.T) { + ii := newAssetIndex() + server := &assets.Asset{ + ID: "s1", + OriginalFileName: "Snapchat-359293977.jpg", + CaptureDate: time.Date(2019, 11, 1, 13, 34, 35, 0, time.UTC), + FileSize: 232793, + Checksum: "c1", + NameInfo: assets.NameInfo{Type: "image"}, + } + ii.add(server, false) + + local := &assets.Asset{ + OriginalFileName: "IMG_1234.jpg", + CaptureDate: time.Date(2019, 11, 1, 13, 34, 35, 0, time.UTC), + FileSize: 232793, + NameInfo: assets.NameInfo{Type: "image"}, + } + + require.Nil(t, ii.findSnapchatFuzzyDuplicate(local)) +} + +func TestFindSnapchatFuzzyDuplicateAdjacentDay(t *testing.T) { + ii := newAssetIndex() + server := &assets.Asset{ + ID: "s1", + OriginalFileName: "Snapchat-12345.jpg", + CaptureDate: time.Date(2019, 12, 23, 0, 2, 0, 0, time.UTC), + FileSize: 101000, + Checksum: "c1", + NameInfo: assets.NameInfo{Type: "image"}, + } + ii.add(server, false) + + local := &assets.Asset{ + OriginalFileName: "2019-12-22_67f47d95-3530-3822-f062-be9a127cf71b-main.jpg", + CaptureDate: time.Date(2019, 12, 22, 23, 59, 0, 0, time.UTC), + FileSize: 100000, + NameInfo: assets.NameInfo{Type: "image"}, + } + + best := ii.findSnapchatFuzzyDuplicate(local) + require.NotNil(t, best) + require.Equal(t, "s1", best.ID) +} + +func TestFindSnapchatFuzzyDuplicateAmbiguousRejected(t *testing.T) { + ii := newAssetIndex() + day := time.Date(2020, 5, 1, 12, 0, 0, 0, time.UTC) + + server1 := &assets.Asset{ + ID: "s1", + OriginalFileName: "Snapchat-111.jpg", + CaptureDate: day, + FileSize: 100000, + Checksum: "c1", + NameInfo: assets.NameInfo{Type: "image"}, + } + server2 := &assets.Asset{ + ID: "s2", + OriginalFileName: "Snapchat-222.jpg", + CaptureDate: day, + FileSize: 101000, + Checksum: "c2", + NameInfo: assets.NameInfo{Type: "image"}, + } + ii.add(server1, false) + ii.add(server2, false) + + local := &assets.Asset{ + OriginalFileName: "2020-05-01_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-main.jpg", + CaptureDate: day, + FileSize: 100400, + NameInfo: assets.NameInfo{Type: "image"}, + } + + require.Nil(t, ii.findSnapchatFuzzyDuplicate(local)) +} + +func TestFindSnapchatFuzzyDuplicateRejectTooFarDay(t *testing.T) { + ii := newAssetIndex() + server := &assets.Asset{ + ID: "s1", + OriginalFileName: "Snapchat-98765.mp4", + CaptureDate: time.Date(2020, 5, 3, 12, 0, 0, 0, time.UTC), + FileSize: 500000, + Checksum: "c1", + NameInfo: assets.NameInfo{Type: "video"}, + } + ii.add(server, false) + + local := &assets.Asset{ + OriginalFileName: "2020-05-01_bbbbbbbb-cccc-dddd-eeee-ffffffffffff-main.mp4", + CaptureDate: time.Date(2020, 5, 1, 12, 0, 0, 0, time.UTC), + FileSize: 510000, + NameInfo: assets.NameInfo{Type: "video"}, + } + + require.Nil(t, ii.findSnapchatFuzzyDuplicate(local)) +} diff --git a/app/upload/upload.go b/app/upload/upload.go index ede80ed51..2004d065a 100644 --- a/app/upload/upload.go +++ b/app/upload/upload.go @@ -9,6 +9,7 @@ import ( "github.com/simulot/immich-go/adapters/folder" "github.com/simulot/immich-go/adapters/fromimmich" gp "github.com/simulot/immich-go/adapters/googlePhotos" + "github.com/simulot/immich-go/adapters/snapchat" "github.com/simulot/immich-go/adapters/shared" "github.com/simulot/immich-go/app" "github.com/simulot/immich-go/immich" @@ -58,6 +59,7 @@ type UpCmd struct { client app.Client NoUI bool // Disable UI Overwrite bool // Always overwrite files on the server with local versions + ConservativeDuplicates bool // Skip uploads when date+size+type strongly match existing server asset Tags []string SessionTag bool session string // Session tag value @@ -83,6 +85,7 @@ func (uc *UpCmd) RegisterFlags(flags *pflag.FlagSet) { uc.client.RegisterFlags(flags, "") flags.BoolVar(&uc.NoUI, "no-ui", false, "Disable the user interface") flags.BoolVar(&uc.Overwrite, "overwrite", false, "Always overwrite files on the server with local versions") + flags.BoolVar(&uc.ConservativeDuplicates, "conservative-duplicates", false, "Treat assets with same capture date (+/-5s), size, and media type as duplicates even when checksum differs") flags.StringSliceVar(&uc.Tags, "tag", nil, "Add tags to the imported assets. Can be specified multiple times. Hierarchy is supported using a / separator (e.g. 'tag1/subtag1')") flags.BoolVar(&uc.SessionTag, "session-tag", false, "Tag uploaded photos with a tag \"{immich-go}/YYYY-MM-DD HH-MM-SS\"") @@ -111,6 +114,7 @@ func NewUploadCommand(ctx context.Context, app *app.Application) *cobra.Command cmd.AddCommand(folder.NewFromICloudCommand(ctx, cmd, app, uc)) cmd.AddCommand(folder.NewFromPicasaCommand(ctx, cmd, app, uc)) cmd.AddCommand(gp.NewFromGooglePhotosCommand(ctx, cmd, app, uc)) + cmd.AddCommand(snapchat.NewFromSnapchatCommand(ctx, cmd, app, uc)) cmd.AddCommand(fromimmich.NewFromImmichCommand(ctx, cmd, app, uc)) cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { diff --git a/docs/commands/README.md b/docs/commands/README.md index be3b81d40..9af6e93be 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -12,8 +12,8 @@ immich-go [global-options] command sub-command [command-options] [path] | Command | Description | Sub-commands | |---------|-------------|--------------| -| [upload](upload.md) | Upload photos/videos to Immich server | from-folder, from-google-photos, from-icloud, from-picasa, from-immich | -| [archive](archive.md) | Export/archive photos to local folder structure | from-folder, from-google-photos, from-icloud, from-picasa, from-immich | +| [upload](upload.md) | Upload photos/videos to Immich server | from-folder, from-google-photos, from-snapchat, from-icloud, from-picasa, from-immich | +| [archive](archive.md) | Export/archive photos to local folder structure | from-folder, from-google-photos, from-snapchat, from-icloud, from-picasa, from-immich | | [stack](stack.md) | Organize related photos into stacks on server | (none) | | version | Display version information | (none) | @@ -63,4 +63,4 @@ immich-go version - [Upload Command](upload.md) - Comprehensive upload options and sub-commands - [Archive Command](archive.md) - Export and archival features -- [Stack Command](stack.md) - Photo organization and stacking \ No newline at end of file +- [Stack Command](stack.md) - Photo organization and stacking diff --git a/docs/commands/archive.md b/docs/commands/archive.md index 07e12221b..7594f7f47 100644 --- a/docs/commands/archive.md +++ b/docs/commands/archive.md @@ -43,6 +43,7 @@ All `upload` sub-commands are available for `archive`: |-------------|--------|-------------| | `from-folder` | Local filesystem | Archive from local folders or ZIP archives | | `from-google-photos` | Google Takeout | Archive from Google Photos takeout | +| `from-snapchat` | Snapchat export | Archive from Snapchat memories exports | | `from-icloud` | iCloud export | Archive from iCloud takeout | | `from-picasa` | Picasa | Archive from Picasa collections | | `from-immich` | Immich server | Archive from Immich server | @@ -121,6 +122,14 @@ immich-go archive from-google-photos \ /path/to/takeout ``` +### Archive Snapchat Memories Export +```bash +# Archive multipart Snapchat export and merge overlays +immich-go archive from-snapchat \ + --write-to-folder=/organized-snapchat \ + /path/to/mydata~*.zip +``` + ### Archive Local Folders ```bash # Reorganize existing photos by date diff --git a/docs/commands/upload.md b/docs/commands/upload.md index 62d26c331..01a75c42d 100644 --- a/docs/commands/upload.md +++ b/docs/commands/upload.md @@ -14,6 +14,7 @@ immich-go upload [options] | ----------------------------------------- | ---------------- | ------------------------------------------ | | [from-folder](#from-folder) | Local filesystem | Upload from local folders or ZIP archives | | [from-google-photos](#from-google-photos) | Google Takeout | Upload from Google Photos takeout archives | +| [from-snapchat](#from-snapchat) | Snapchat export | Upload from Snapchat memories exports | | [from-icloud](#from-icloud) | iCloud export | Upload from iCloud takeout | | [from-picasa](#from-picasa) | Picasa | Upload from Picasa photo collections | | [from-immich](#from-immich) | Immich server | Transfer between Immich servers | @@ -36,6 +37,7 @@ All upload sub-commands require these connection parameters: | `--dry-run` | `false` | Simulate upload without actual transfers | | `--concurrent-tasks` | CPU cores | Number of parallel tasks (1-20) | | `--overwrite` | `false` | Replace existing files on server | +| `--conservative-duplicates` | `false` | For general imports: treat same capture date (+/-5s), size, and type as duplicates; for `from-snapchat`: also use Snapchat-aware fuzzy matching against existing `Snapchat-*` assets | | `--pause-immich-jobs` | `true` | Pause server jobs during upload | | `--on-errors` | `stop` | Action on errors: `stop`, `continue`, or tolerated number of errors | @@ -170,6 +172,40 @@ immich-go upload from-google-photos --from-album-name="Vacation 2023" --server=h --- +## from-snapchat + +Upload from Snapchat memories exports (`mydata~*.zip`), including multipart exports. + +### Usage +```bash +immich-go upload from-snapchat [options] | +``` + +### Behavior + +- Reads metadata from `json/memories_history.json` when present +- Matches metadata to media using Snapchat memory IDs (`sid`/`mid`) +- Detects paired files in `memories/` with naming pattern `*-main.*` and `*-overlay.png` +- Automatically merges overlays onto the main media before upload +- If overlay merge fails (for example missing `ffmpeg` for video), uploads the main media and logs a warning + +### File Management +Same options as `from-folder` for burst, RAW/JPEG, and HEIC/JPEG management. + +### Examples +```bash +# Import multipart Snapchat export zips +immich-go upload from-snapchat --server=http://localhost:2283 --api-key=your-key /path/to/mydata~*.zip + +# Import from extracted takeout directory +immich-go upload from-snapchat --server=http://localhost:2283 --api-key=your-key /path/to/snapchat-export + +# Skip likely duplicates with different filenames/encodings +immich-go upload from-snapchat --conservative-duplicates --server=http://localhost:2283 --api-key=your-key /path/to/mydata~*.zip +``` + +--- + ## from-icloud Upload from iCloud takeout archives. @@ -270,4 +306,4 @@ immich-go upload from-immich \ - [Configuration Options](../configuration.md) - [Technical Details](../technical.md) -- [Best Practices](../best-practices.md) \ No newline at end of file +- [Best Practices](../best-practices.md) diff --git a/docs/examples.md b/docs/examples.md index b5c5915f0..09babf101 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -8,6 +8,7 @@ This guide provides practical examples for common Immich-Go scenarios. |----------|---------|---------------| | [Upload local photos](#local-photo-upload) | `upload from-folder` | Basic photo upload | | [Google Photos migration](#google-photos-migration) | `upload from-google-photos` | Takeout import | +| [Snapchat migration](#snapchat-migration) | `upload from-snapchat` | Snapchat memories export | | [iCloud import](#icloud-import) | `upload from-icloud` | iCloud takeout | | [Server backup](#server-backup) | `archive from-immich` | Full server archive | | [Server migration](#server-migration) | `upload from-immich` | Transfer between servers | @@ -105,6 +106,35 @@ immich-go upload from-google-photos \ /downloads/takeout-*.zip ``` +## Snapchat Migration + +### Multipart Export Import +```bash +# Import Snapchat mydata export parts and merge overlays +immich-go upload from-snapchat \ + --server=http://localhost:2283 \ + --api-key=your-api-key \ + /downloads/mydata~*.zip +``` + +### Conservative Duplicate Mode +```bash +# Avoid re-importing likely existing media with different filenames +immich-go upload from-snapchat \ + --server=http://localhost:2283 \ + --api-key=your-api-key \ + --conservative-duplicates \ + /downloads/mydata~*.zip +``` + +### Archive Snapchat Export +```bash +# Rebuild a clean local archive from Snapchat export +immich-go archive from-snapchat \ + --write-to-folder=/organized/snapchat \ + /downloads/mydata~*.zip +``` + ## iCloud Import ### Basic iCloud Import diff --git a/docs/upload-commands-overview.md b/docs/upload-commands-overview.md index 86212293c..a1b6bc90c 100644 --- a/docs/upload-commands-overview.md +++ b/docs/upload-commands-overview.md @@ -221,3 +221,46 @@ When migrating assets, `from-immich` ensures that all metadata is preserved: * **Albums and Tags**: The assets' associations with albums and tags are fetched from the source server. When they are uploaded to the destination server, `immich-go` will recreate those albums and tags. * **Other Metadata**: Descriptions, GPS coordinates, ratings, and other EXIF/XMP information are all carried over to the destination server. + +## 4. The `from-snapchat` Command: Importing Snapchat Memories Exports + +The `from-snapchat` command imports Snapchat data exports directly from zip parts (`mydata~*.zip`) or an extracted export folder. + +### How it Works + +`from-snapchat` uses a two-pass approach: + +1. **Discovery pass**: + * Scans all inputs for `json/memories_history.json` and parses capture date + location metadata. + * Scans `memories/` entries and identifies media files (`*-main.*`) and overlay files (`*-overlay.png`). + * Matches entries by Snapchat ID (`sid`/`mid`) extracted from JSON links and filenames. + +2. **Asset pass**: + * Builds assets from `*-main.*` files. + * Applies metadata from `memories_history.json` (capture date and GPS coordinates). + * If an overlay exists, merges it into the main media before upload/archive. + +### Overlay Merging + +Snapchat often exports overlays (text/stickers) as separate files. `from-snapchat` reconstructs the final media automatically: + +* **Images**: Resizes `*-overlay.png` to the main image dimensions and composites it. +* **Videos**: Uses `ffmpeg` with `scale2ref + overlay` to burn the overlay onto the video stream. + +If video overlay merge fails (for example `ffmpeg` missing), `immich-go` logs a warning and falls back to the original main video so import can continue. + +### Integration with Existing Pipeline + +After Snapchat-specific preprocessing, assets enter the standard `immich-go` pipeline, so duplicate detection, filtering, stacking, album/tag logic, and archive behavior remain consistent with other import commands. + +### Conservative Duplicate Mode + +When `--conservative-duplicates` is enabled (upload command), `immich-go` also considers an asset as already present if all three conditions match an existing server asset: + +* Capture date within +/-5 seconds +* Same file size +* Same media type (image/video) + +This mode is useful for Snapchat migrations where filenames and checksums can differ from already-imported media due to re-exports or overlay reconstruction. + +For `from-snapchat`, conservative mode also includes a Snapchat-specific fallback that compares same-day assets against existing `Snapchat-*` server assets using media type and size similarity. This helps detect likely duplicates when filenames and checksums differ significantly between Snapchat export files and already imported Immich assets. diff --git a/internal/assettracker/tracker.go b/internal/assettracker/tracker.go index 6026bd134..15bcdf2b8 100644 --- a/internal/assettracker/tracker.go +++ b/internal/assettracker/tracker.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strings" "sync" "time" @@ -162,11 +163,10 @@ func (at *AssetTracker) SetProcessed(file fshelper.FSAndName, eventCode fileeven at.mu.Lock() defer at.mu.Unlock() - key := file.FullName() - record, exists := at.assets[key] + key, record, exists := at.findRecordForFinalization(file) if !exists { if at.log != nil { - at.log.Error("SetProcessed: asset not found", "file", key, "code", eventCode) + at.log.Error("SetProcessed: asset not found", "file", file.FullName(), "code", eventCode) } return } @@ -208,11 +208,10 @@ func (at *AssetTracker) SetDiscarded(file fshelper.FSAndName, eventCode fileeven at.mu.Lock() defer at.mu.Unlock() - key := file.FullName() - record, exists := at.assets[key] + key, record, exists := at.findRecordForFinalization(file) if !exists { if at.log != nil { - at.log.Error("SetDiscarded: asset not found", "file", key, "code", eventCode, "reason", reason) + at.log.Error("SetDiscarded: asset not found", "file", file.FullName(), "code", eventCode, "reason", reason) } return } @@ -256,11 +255,10 @@ func (at *AssetTracker) SetError(file fshelper.FSAndName, eventCode fileevent.Co at.mu.Lock() defer at.mu.Unlock() - key := file.FullName() - record, exists := at.assets[key] + key, record, exists := at.findRecordForFinalization(file) if !exists { if at.log != nil { - at.log.Error("SetError: asset not found", "file", key, "code", eventCode, "error", err.Error()) + at.log.Error("SetError: asset not found", "file", file.FullName(), "code", eventCode, "error", err.Error()) } return } @@ -476,3 +474,34 @@ func formatBytes(bytes int64) string { } return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) } + +func (at *AssetTracker) findRecordForFinalization(file fshelper.FSAndName) (string, *AssetRecord, bool) { + key := file.FullName() + record, ok := at.assets[key] + if ok { + return key, record, true + } + + // Compatibility lookup for Snapchat merged assets. + // Some discovery paths can register the original main file while processing + // finalizes with a synthetic key like: + // "snapchat-merged::merged". + const ( + snapPrefix = "snapchat-merged:" + snapSuffix = ":merged" + ) + if strings.HasPrefix(key, snapPrefix) && strings.HasSuffix(key, snapSuffix) { + fallback := strings.TrimSuffix(strings.TrimPrefix(key, snapPrefix), snapSuffix) + if fallback != "" { + record, ok = at.assets[fallback] + if ok { + if at.log != nil { + at.log.Debug("AssetTracker used snapchat fallback key", "from", key, "to", fallback) + } + return fallback, record, true + } + } + } + + return key, nil, false +} diff --git a/internal/assettracker/tracker_test.go b/internal/assettracker/tracker_test.go index 01cdcf4d2..16378e91a 100644 --- a/internal/assettracker/tracker_test.go +++ b/internal/assettracker/tracker_test.go @@ -25,6 +25,18 @@ func (m mockFS) Name() string { return "test.zip" } +type mergedMockFS struct { + name string +} + +func (m mergedMockFS) Open(name string) (fs.File, error) { + return nil, fs.ErrNotExist +} + +func (m mergedMockFS) Name() string { + return m.name +} + func TestNew(t *testing.T) { tracker := New() if tracker == nil { @@ -105,6 +117,69 @@ func TestSetProcessed(t *testing.T) { } } +func TestSetProcessedSnapchatMergedFallback(t *testing.T) { + tracker := New() + + original := fshelper.FSName(mockFS{}, "memories/2020-01-01_abc-main.jpg") + tracker.DiscoverAsset(original, 1234, fileevent.DiscoveredImage) + + merged := fshelper.FSName(mergedMockFS{name: "snapchat-merged:" + original.FullName()}, "merged") + tracker.SetProcessed(merged, fileevent.ProcessedUploadSuccess) + + counters := tracker.GetCounters() + if counters.Processed != 1 { + t.Errorf("expected 1 processed asset, got %d", counters.Processed) + } + if counters.Pending != 0 { + t.Errorf("expected 0 pending assets, got %d", counters.Pending) + } + if counters.ProcessedSize != 1234 { + t.Errorf("expected processed size 1234, got %d", counters.ProcessedSize) + } +} + +func TestSetDiscardedSnapchatMergedFallback(t *testing.T) { + tracker := New() + + original := fshelper.FSName(mockFS{}, "memories/2020-01-01_abc-main.jpg") + tracker.DiscoverAsset(original, 1000, fileevent.DiscoveredImage) + + merged := fshelper.FSName(mergedMockFS{name: "snapchat-merged:" + original.FullName()}, "merged") + tracker.SetDiscarded(merged, fileevent.DiscardedServerDuplicate, "server has duplicate") + + counters := tracker.GetCounters() + if counters.Discarded != 1 { + t.Errorf("expected 1 discarded asset, got %d", counters.Discarded) + } + if counters.Pending != 0 { + t.Errorf("expected 0 pending assets, got %d", counters.Pending) + } + if counters.DiscardedSize != 1000 { + t.Errorf("expected discarded size 1000, got %d", counters.DiscardedSize) + } +} + +func TestSetErrorSnapchatMergedFallback(t *testing.T) { + tracker := New() + + original := fshelper.FSName(mockFS{}, "memories/2020-01-01_abc-main.mp4") + tracker.DiscoverAsset(original, 2000, fileevent.DiscoveredVideo) + + merged := fshelper.FSName(mergedMockFS{name: "snapchat-merged:" + original.FullName()}, "merged") + tracker.SetError(merged, fileevent.ErrorUploadFailed, fs.ErrPermission) + + counters := tracker.GetCounters() + if counters.Errors != 1 { + t.Errorf("expected 1 error asset, got %d", counters.Errors) + } + if counters.Pending != 0 { + t.Errorf("expected 0 pending assets, got %d", counters.Pending) + } + if counters.ErrorSize != 2000 { + t.Errorf("expected error size 2000, got %d", counters.ErrorSize) + } +} + func TestSetDiscarded(t *testing.T) { tracker := New() file := fshelper.FSName(mockFS{}, "duplicate.jpg") diff --git a/readme.md b/readme.md index 518776e01..c73fa31ec 100644 --- a/readme.md +++ b/readme.md @@ -63,6 +63,7 @@ Here's a brief overview of the main upload commands: * **`from-folder`**: The basic command for uploading from any local folder. It can create albums from your directory structure and read XMP sidecar files. * **`from-google-photos`**: A powerful command to migrate from a Google Photos Takeout. It intelligently matches photos with their JSON metadata to preserve albums, descriptions, and locations. +* **`from-snapchat`**: Imports Snapchat memories exports (`mydata~*.zip`), maps metadata from `memories_history.json`, and merges Snapchat overlays back into the original media. * **`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.