Skip to content

Commit 9850c0a

Browse files
Cedric Reginstercederigo
authored andcommitted
add support for icloud takeouts
supports albums and original creation dates. request copy of your data from: https://privacy.apple.com
1 parent d16c85d commit 9850c0a

File tree

13 files changed

+176
-1
lines changed

13 files changed

+176
-1
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
imgName
2+
photo2.jpg
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
imgName
2+
photo1.jpg
3+
photo2.jpg
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
imgName
2+
photo1.jpg
3+
photo2.jpg
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
imgName,fileChecksum,favorite,hidden,deleted,originalCreationDate,viewCount,importDate
2+
photo_wo_exif.jpg,ignored-checksum,no,no,no,"Friday October 06,2023 12:11 PM GMT",10,"Friday October 06,2023 12:11 PM GMT"
Loading
Loading
Loading

adapters/folder/icloud.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package folder
2+
3+
import (
4+
"encoding/csv"
5+
"errors"
6+
"io/fs"
7+
"path/filepath"
8+
"strings"
9+
"time"
10+
11+
"github.com/simulot/immich-go/internal/assets"
12+
"github.com/simulot/immich-go/internal/gen"
13+
)
14+
15+
type iCloudMeta struct {
16+
albums []assets.Album
17+
originalCreationDate time.Time
18+
}
19+
20+
func UseICloudAlbum(m *gen.SyncMap[string, iCloudMeta], fsys fs.FS, filename string) (string, error) {
21+
file, err := fsys.Open(filename)
22+
if err != nil {
23+
return "", err
24+
}
25+
defer file.Close()
26+
27+
albumName := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
28+
reader := csv.NewReader(file)
29+
records, err := reader.ReadAll()
30+
if err != nil {
31+
return "", errors.Join(err, errors.New("failed to read all csv records"))
32+
}
33+
for _, record := range records[1:] {
34+
if len(record) != 1 {
35+
return "", errors.Join(err, errors.New("invalid record"))
36+
}
37+
fileName := record[0]
38+
meta, _ := m.Load(fileName)
39+
meta.albums = append(meta.albums, assets.Album{Title: albumName})
40+
m.Store(fileName, meta)
41+
}
42+
43+
return albumName, nil
44+
}
45+
46+
// Example:
47+
// imgName,fileChecksum,favorite,hidden,deleted,originalCreationDate,viewCount,importDate
48+
// IMG_7938.HEIC,AfQj57ORF2JIumUCjO+PawZ9nqPg,no,no,no,"Saturday June 4,2022 12:11 PM GMT",10,"Saturday June 4,2022 12:11 PM GMT"
49+
func UseICloudPhotoDetails(m *gen.SyncMap[string, iCloudMeta], fsys fs.FS, filename string) error {
50+
file, err := fsys.Open(filename)
51+
if err != nil {
52+
return err
53+
}
54+
defer file.Close()
55+
56+
reader := csv.NewReader(file)
57+
records, err := reader.ReadAll()
58+
if err != nil {
59+
return errors.Join(err, errors.New("failed to read all csv records"))
60+
}
61+
// skip header
62+
for _, record := range records[1:] {
63+
if len(record) != 8 {
64+
return errors.Join(err, errors.New("invalid record"))
65+
}
66+
fileName := record[0]
67+
originalCreationDate := record[5]
68+
t, err := time.Parse("Monday January 2,2006 15:04 PM GMT", originalCreationDate)
69+
if err != nil {
70+
return errors.Join(err, errors.New("invalid original creation date"))
71+
}
72+
meta, _ := m.Load(fileName)
73+
meta.originalCreationDate = t
74+
m.Store(fileName, meta)
75+
}
76+
77+
return nil
78+
}

adapters/folder/options.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ type ImportFolderOptions struct {
7979
// Use picasa albums
8080
PicasaAlbum bool
8181

82+
// Use icloud takeout metadata (albums & creation date)
83+
ICloudTakeout bool
84+
8285
// local time zone
8386
TZ *time.Location
8487
}
@@ -123,6 +126,7 @@ func (o *ImportFolderOptions) AddFromFolderFlags(cmd *cobra.Command, parent *cob
123126
cmd.Flags().Var(&o.ManageBurst, "manage-burst", "Manage burst photos. Possible values: NoStack, Stack, StackKeepRaw, StackKeepJPEG")
124127
cmd.Flags().BoolVar(&o.ManageEpsonFastFoto, "manage-epson-fastfoto", false, "Manage Epson FastFoto file (default: false)")
125128
cmd.Flags().BoolVar(&o.PicasaAlbum, "album-picasa", false, "Use Picasa album name found in .picasa.ini file (default: false)")
129+
cmd.Flags().BoolVar(&o.ICloudTakeout, "icloud-takeout", false, "Use metadata from icloud takeout (Albums & original creation dates) (default: false)")
126130
}
127131
}
128132

adapters/folder/readFolder.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type LocalAssetBrowser struct {
3939
groupers []groups.Grouper
4040
requiresDateInformation bool // true if we need to read the date from the file for the options
4141
picasaAlbums *gen.SyncMap[string, PicasaAlbum] // ap[string]PicasaAlbum
42+
icloudMetas *gen.SyncMap[string, iCloudMeta]
4243
}
4344

4445
func NewLocalFiles(ctx context.Context, l *fileevent.Recorder, flags *ImportFolderOptions, fsyss ...fs.FS) (*LocalAssetBrowser, error) {
@@ -59,6 +60,9 @@ func NewLocalFiles(ctx context.Context, l *fileevent.Recorder, flags *ImportFold
5960
if flags.PicasaAlbum {
6061
la.picasaAlbums = gen.NewSyncMap[string, PicasaAlbum]() // make(map[string]PicasaAlbum)
6162
}
63+
if flags.ICloudTakeout {
64+
la.icloudMetas = gen.NewSyncMap[string, iCloudMeta]()
65+
}
6266

6367
if flags.InfoCollector == nil {
6468
flags.InfoCollector = filenames.NewInfoCollector(flags.TZ, flags.SupportedMedia)
@@ -140,6 +144,8 @@ func (la *LocalAssetBrowser) parseDir(ctx context.Context, fsys fs.FS, dir strin
140144
for _, entry := range entries {
141145
base := entry.Name()
142146
name := path.Join(dir, base)
147+
ext := filepath.Ext(base)
148+
143149
if entry.IsDir() {
144150
continue
145151
}
@@ -154,6 +160,35 @@ func (la *LocalAssetBrowser) parseDir(ctx context.Context, fsys fs.FS, dir strin
154160
continue
155161
}
156162

163+
// iCloud albums
164+
if la.flags.ICloudTakeout && strings.ToLower(dir) == "albums" && ext == ".csv" {
165+
a, err := UseICloudAlbum(la.icloudMetas, fsys, name)
166+
if err != nil {
167+
la.log.Record(ctx, fileevent.Error, fshelper.FSName(fsys, name), "error", err.Error())
168+
} else {
169+
la.log.Log().Info("iCloud album detected", "file", fshelper.FSName(fsys, name), "album", a)
170+
}
171+
continue
172+
}
173+
174+
// iCloud memories
175+
if la.flags.ICloudTakeout && strings.ToLower(dir) == "memories" && ext == ".csv" {
176+
// ignore
177+
la.log.Record(ctx, fileevent.DiscoveredDiscarded, fshelper.FSName(fsys, name), "reason", "iCloud memories ignored")
178+
continue
179+
}
180+
181+
// iCloud photo details (csv). File name pattern: "Photo Details.csv"
182+
if la.flags.ICloudTakeout && strings.HasPrefix(strings.ToLower(base), "photo details") && ext == ".csv" {
183+
err := UseICloudPhotoDetails(la.icloudMetas, fsys, name)
184+
if err != nil {
185+
la.log.Record(ctx, fileevent.Error, fshelper.FSName(fsys, name), "error", err.Error())
186+
} else {
187+
la.log.Log().Info("iCloud photo details detected", "file", fshelper.FSName(fsys, name))
188+
}
189+
continue
190+
}
191+
157192
if la.flags.PicasaAlbum && (strings.ToLower(base) == ".picasa.ini" || strings.ToLower(base) == "picasa.ini") {
158193
a, err := ReadPicasaIni(fsys, name)
159194
if err != nil {
@@ -165,7 +200,6 @@ func (la *LocalAssetBrowser) parseDir(ctx context.Context, fsys fs.FS, dir strin
165200
continue
166201
}
167202

168-
ext := filepath.Ext(base)
169203
mediaType := la.flags.SupportedMedia.TypeFromExt(ext)
170204

171205
if mediaType == filetypes.TypeUnknown {
@@ -290,6 +324,16 @@ func (la *LocalAssetBrowser) parseDir(ctx context.Context, fsys fs.FS, dir strin
290324

291325
// Read metadata from the file only id needed (date range or take date from filename)
292326
if la.requiresDateInformation {
327+
// try to get date from icloud takeout meta
328+
if a.CaptureDate.IsZero() && la.flags.ICloudTakeout {
329+
meta, ok := la.icloudMetas.Load(a.OriginalFileName)
330+
if ok {
331+
a.FromApplication = &assets.Metadata{
332+
DateTaken: meta.originalCreationDate,
333+
}
334+
a.CaptureDate = a.FromApplication.DateTaken
335+
}
336+
}
293337
if a.CaptureDate.IsZero() {
294338
// no date in XMP, JSON, try reading the metadata
295339
f, err := a.OpenFile()
@@ -309,6 +353,7 @@ func (la *LocalAssetBrowser) parseDir(ctx context.Context, fsys fs.FS, dir strin
309353
}
310354
f.Close()
311355
}
356+
312357
}
313358
}
314359

@@ -347,6 +392,12 @@ func (la *LocalAssetBrowser) parseDir(ctx context.Context, fsys fs.FS, dir strin
347392
done = true
348393
}
349394
}
395+
if la.flags.ICloudTakeout {
396+
if meta, ok := la.icloudMetas.Load(a.OriginalFileName); ok {
397+
a.Albums = meta.albums
398+
done = true
399+
}
400+
}
350401
if !done && la.flags.UsePathAsAlbumName != FolderModeNone && la.flags.UsePathAsAlbumName != "" {
351402
Album := ""
352403
switch la.flags.UsePathAsAlbumName {

adapters/folder/readFolderWithFIles_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,35 @@ func TestLocalAssets(t *testing.T) {
119119
Set(fileevent.DiscoveredImage, 2).Set(fileevent.DiscoveredDiscarded, 1).Set(fileevent.Uploaded, 1).
120120
Set(fileevent.INFO, 1).Value(),
121121
},
122+
{
123+
name: "icloud-takeout",
124+
flags: ImportFolderOptions{
125+
SupportedMedia: filetypes.DefaultSupportedMedia,
126+
Recursive: true,
127+
ICloudTakeout: true,
128+
TZ: time.Local,
129+
InclusionFlags: cliflags.InclusionFlags{
130+
DateRange: cliflags.InitDateRange(time.Local, "2023-10-06"),
131+
},
132+
},
133+
fsys: []fs.FS{
134+
os.DirFS("DATA/icloud-takeout"),
135+
},
136+
expectedFiles: []string{
137+
"Photos/photo1.jpg",
138+
"Photos/photo2.jpg",
139+
"Photos/photo_wo_exif.jpg",
140+
},
141+
expectedAlbums: map[string][]string{
142+
"Spécial album": {"Photos/photo1.jpg", "Photos/photo2.jpg"},
143+
"Spécial album 2": {"Photos/photo2.jpg"},
144+
},
145+
expectedCounts: fileevent.NewCounts().
146+
Set(fileevent.DiscoveredDiscarded, 1).
147+
Set(fileevent.DiscoveredImage, 3).
148+
Set(fileevent.Uploaded, 3).
149+
Value(),
150+
},
122151
}
123152

124153
logFile := configuration.DefaultLogFile()

immich/ping.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,5 +127,6 @@ func (ic *ImmichClient) GetSupportedMediaTypes(ctx context.Context) (filetypes.S
127127
}
128128
sm[".mp"] = filetypes.TypeUseless
129129
sm[".json"] = filetypes.TypeSidecar
130+
sm[".csv"] = filetypes.TypeMeta
130131
return sm, err
131132
}

internal/filetypes/supported.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const (
1515
TypeImage = "image"
1616
TypeSidecar = "sidecar"
1717
TypeUseless = "useless"
18+
TypeMeta = "meta"
1819
TypeUnknown = ""
1920
)
2021

@@ -27,6 +28,7 @@ var DefaultSupportedMedia = SupportedMedia{
2728
".xmp": TypeSidecar,
2829
".json": TypeSidecar,
2930
".mp": TypeUseless,
31+
".csv": TypeMeta,
3032
}
3133

3234
func (sm SupportedMedia) TypeFromName(name string) string {

0 commit comments

Comments
 (0)