iclouddrive: add read only iCloud Photos support and SRP authentication
Add read-only iCloud Photos support to the existing iclouddrive backend via `service = photos` config option. Also includes auth improvements on top of #9209's SRP authentication. **Photos features:** - 3-level hierarchy: libraries (Personal + Shared Photo Library) → albums → photos/videos - server-side smart albums (All Photos, Videos, Favorites, Screenshots, Live, Bursts, Panoramas, Slo-mo, Time-lapse, Portrait, Long Exposure, Animated, Hidden, Recently Deleted) - User-created albums and nested album folders - Live Photo `.MOV` companions as first-class entries - Edited photo versions (`-edited` suffix) and RAW alternatives - Duplicate filename dedup for camera counter wrap collisions - Parallel cold listing for large albums - Delta sync via CloudKit `changes/zone` - warm listings near-instant from disk cache - Disk cache (libraries, albums, photos) with atomic writes for crash safety - `ChangeNotify` support for FUSE mounts via `changes/zone` polling - `ListR` support for `--fast-list` and recursive operations - `--metadata` support - width, height, added-time, favorite, hidden - Fresh download URLs per file - no stale URL failures on long copies - FUSE mount documentation with recommended flags **Auth improvements over #9209:** - SMS 2FA fallback for users without trusted Apple devices - Explicit push notification request - fixes iOS/macOS 26.4+ where 409 no longer auto-pushes - Thread safety for concurrent FUSE callers (mutexes on session and client state) - Session endpoint caching - skips ~5s `/validate` round-trip on warm start - `Disconnect` support - clears auth state + disk cache - PCS cookie support for Advanced Data Protection accounts, including trusted-device approval for PCS cookies Built on @coughlanio's Photos PoC (Closes #8734) and @mikegillan's SRP auth (#9209). Fixes #7982 Co-authored-by: Chris Coughlan <chris@coughlan.io>
This commit is contained in:
@@ -0,0 +1,892 @@
|
||||
//go:build !plan9 && !solaris
|
||||
|
||||
package iclouddrive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rclone/rclone/backend/iclouddrive/api"
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config/configmap"
|
||||
"github.com/rclone/rclone/fs/fshttp"
|
||||
"github.com/rclone/rclone/fs/hash"
|
||||
"github.com/rclone/rclone/fs/list"
|
||||
"github.com/rclone/rclone/lib/dircache"
|
||||
"github.com/rclone/rclone/lib/pacer"
|
||||
)
|
||||
|
||||
const rootID = "photos-root"
|
||||
|
||||
// PhotosFs represents a remote iCloud Photos server
|
||||
type PhotosFs struct {
|
||||
name string
|
||||
root string
|
||||
opt Options
|
||||
features *fs.Features
|
||||
icloud *api.Client
|
||||
m configmap.Mapper
|
||||
pacer *fs.Pacer
|
||||
dirCache *dircache.DirCache
|
||||
httpClient *http.Client
|
||||
startTime time.Time
|
||||
mu sync.Mutex
|
||||
photos *api.PhotosService
|
||||
}
|
||||
|
||||
// PhotosObject describes an iCloud Photos object
|
||||
type PhotosObject struct {
|
||||
fs *PhotosFs
|
||||
remote string
|
||||
size int64
|
||||
modTime time.Time
|
||||
masterID string // CloudKit recordName for fresh URL lookup (master or asset depending on resource)
|
||||
zone string // CloudKit zone (e.g. "PrimarySync")
|
||||
resourceKey string // CloudKit resource field (resOriginalRes or resOriginalVidComplRes)
|
||||
width int
|
||||
height int
|
||||
addedDate int64
|
||||
isFavorite bool
|
||||
isHidden bool
|
||||
}
|
||||
|
||||
// NewFsPhotos constructs an Fs for Photos from the path, container:path
|
||||
func NewFsPhotos(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
|
||||
icloud, opt, err := newICloudClient(ctx, name, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
root = strings.Trim(root, "/")
|
||||
|
||||
f := &PhotosFs{
|
||||
name: name,
|
||||
root: root,
|
||||
opt: *opt,
|
||||
icloud: icloud,
|
||||
m: m,
|
||||
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
|
||||
httpClient: fshttp.NewClient(ctx),
|
||||
startTime: time.Now(),
|
||||
}
|
||||
|
||||
f.features = (&fs.Features{
|
||||
CanHaveEmptyDirectories: true,
|
||||
PartialUploads: false,
|
||||
ReadMimeType: false,
|
||||
ReadMetadata: true,
|
||||
}).Fill(ctx, f)
|
||||
|
||||
f.dirCache = dircache.New(root, rootID, f)
|
||||
|
||||
err = f.dirCache.FindRoot(ctx, false)
|
||||
if err != nil {
|
||||
// Check if root points to a file (e.g. PrimarySync/Videos/file.mp4)
|
||||
newRoot, remote := dircache.SplitPath(root)
|
||||
tempF := &PhotosFs{
|
||||
name: f.name,
|
||||
root: newRoot,
|
||||
opt: f.opt,
|
||||
features: f.features,
|
||||
icloud: f.icloud,
|
||||
pacer: f.pacer,
|
||||
httpClient: f.httpClient,
|
||||
startTime: f.startTime,
|
||||
photos: f.photos, // nil here; photosService() creates lazily on first use
|
||||
}
|
||||
tempF.dirCache = dircache.New(newRoot, rootID, tempF)
|
||||
if err2 := tempF.dirCache.FindRoot(ctx, false); err2 != nil {
|
||||
return f, nil
|
||||
}
|
||||
_, err2 := tempF.NewObject(ctx, remote)
|
||||
if err2 != nil {
|
||||
return f, nil
|
||||
}
|
||||
// Root is a file - adjust f to parent dir, signal ErrorIsFile
|
||||
f.root = newRoot
|
||||
f.dirCache = tempF.dirCache
|
||||
return f, fs.ErrorIsFile
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// photosService returns the PhotosService, creating it on first call
|
||||
func (f *PhotosFs) photosService(ctx context.Context) (*api.PhotosService, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.photos == nil {
|
||||
var err error
|
||||
f.photos, err = api.NewPhotosService(ctx, f.icloud, f.pacer, shouldRetry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return f.photos, nil
|
||||
}
|
||||
|
||||
// Name of the remote (as passed into NewFs)
|
||||
func (f *PhotosFs) Name() string {
|
||||
return f.name
|
||||
}
|
||||
|
||||
// Root of the remote (as passed into NewFs)
|
||||
func (f *PhotosFs) Root() string {
|
||||
return f.opt.Enc.ToStandardPath(f.root)
|
||||
}
|
||||
|
||||
// String converts this Fs to a string
|
||||
func (f *PhotosFs) String() string {
|
||||
return fmt.Sprintf("iCloud Photos root '%s'", f.root)
|
||||
}
|
||||
|
||||
// Precision of the object storage system
|
||||
func (f *PhotosFs) Precision() time.Duration {
|
||||
return time.Second
|
||||
}
|
||||
|
||||
// Hashes returns the supported hash sets
|
||||
func (f *PhotosFs) Hashes() hash.Set {
|
||||
return hash.Set(hash.None)
|
||||
}
|
||||
|
||||
// Features returns the optional features of this Fs
|
||||
func (f *PhotosFs) Features() *fs.Features {
|
||||
return f.features
|
||||
}
|
||||
|
||||
// List the objects and directories in dir into entries
|
||||
func (f *PhotosFs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) {
|
||||
photosService, err := f.photosService(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get photos service: %w", err)
|
||||
}
|
||||
|
||||
dirID, err := f.dirCache.FindDir(ctx, dir, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch {
|
||||
case dirID == rootID:
|
||||
// List libraries
|
||||
libraries, err := photosService.GetLibraries(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
albumCounts, err := photosService.GetLibraryAlbumCounts(ctx)
|
||||
if err != nil {
|
||||
fs.Debugf(f, "Failed to get library album counts: %v", err)
|
||||
}
|
||||
|
||||
for libraryName := range libraries {
|
||||
count := int64(-1)
|
||||
if albumCounts != nil {
|
||||
if c, ok := albumCounts[libraryName]; ok {
|
||||
count = c
|
||||
}
|
||||
}
|
||||
d := fs.NewDir(path.Join(dir, f.opt.Enc.ToStandardName(libraryName)), f.startTime).SetItems(count)
|
||||
entries = append(entries, d)
|
||||
}
|
||||
|
||||
case strings.HasPrefix(dirID, "lib:"):
|
||||
// List albums in a library
|
||||
libraryName := strings.TrimPrefix(dirID, "lib:")
|
||||
libraries, err := photosService.GetLibraries(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
library, exists := libraries[libraryName]
|
||||
if !exists {
|
||||
return nil, fs.ErrorDirNotFound
|
||||
}
|
||||
|
||||
albums, err := library.GetAlbums(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
counts, err := library.GetAlbumCounts(ctx)
|
||||
if err != nil {
|
||||
fs.Debugf(f, "Failed to get album counts for library %q: %v", libraryName, err)
|
||||
}
|
||||
|
||||
for albumName := range albums {
|
||||
count := int64(-1)
|
||||
if counts != nil {
|
||||
if c, ok := counts[albumName]; ok {
|
||||
count = c
|
||||
}
|
||||
}
|
||||
d := fs.NewDir(path.Join(dir, f.opt.Enc.ToStandardName(albumName)), f.startTime).SetItems(count)
|
||||
entries = append(entries, d)
|
||||
}
|
||||
|
||||
case strings.HasPrefix(dirID, "album:"):
|
||||
// List contents of an album or folder
|
||||
libraryName, albumPath, ok := parseAlbumDirID(dirID)
|
||||
if !ok {
|
||||
return nil, fs.ErrorDirNotFound
|
||||
}
|
||||
|
||||
album, err := f.resolveAlbum(ctx, photosService, libraryName, albumPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Folders list their child albums as subdirectories
|
||||
if album.IsFolder {
|
||||
for childName, child := range album.Children {
|
||||
d := fs.NewDir(path.Join(dir, f.opt.Enc.ToStandardName(childName)), f.startTime)
|
||||
if child.IsFolder {
|
||||
d.SetItems(int64(len(child.Children)))
|
||||
}
|
||||
entries = append(entries, d)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
photos, err := album.GetPhotos(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, photo := range photos {
|
||||
if photo.Filename == "" {
|
||||
continue
|
||||
}
|
||||
encodedName := f.opt.Enc.ToStandardName(photo.Filename)
|
||||
remotePath := encodedName
|
||||
if dir != "" {
|
||||
remotePath = path.Join(dir, encodedName)
|
||||
}
|
||||
o := f.newPhotosObject(remotePath, photo, libraryName)
|
||||
entries = append(entries, o)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fs.ErrorDirNotFound
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// NewObject finds the Object at remote. If it can't be found
|
||||
// it returns the error fs.ErrorObjectNotFound
|
||||
func (f *PhotosFs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
|
||||
dir, leaf := dircache.SplitPath(remote)
|
||||
filename := f.opt.Enc.FromStandardName(leaf)
|
||||
|
||||
dirID, err := f.dirCache.FindDir(ctx, dir, false)
|
||||
if err != nil {
|
||||
fs.Debugf(f, "NewObject(%s): FindDir: %v", remote, err)
|
||||
return nil, fs.ErrorObjectNotFound
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(dirID, "album:") {
|
||||
return nil, fs.ErrorObjectNotFound
|
||||
}
|
||||
|
||||
libraryName, albumPath, ok := parseAlbumDirID(dirID)
|
||||
if !ok {
|
||||
return nil, fs.ErrorObjectNotFound
|
||||
}
|
||||
|
||||
photosService, err := f.photosService(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get photos service: %w", err)
|
||||
}
|
||||
|
||||
album, err := f.resolveAlbum(ctx, photosService, libraryName, albumPath)
|
||||
if err != nil || album.IsFolder {
|
||||
fs.Debugf(f, "NewObject(%s): album resolve failed or is folder", remote)
|
||||
return nil, fs.ErrorObjectNotFound
|
||||
}
|
||||
|
||||
photo, err := album.GetPhotoByName(ctx, filename)
|
||||
if err != nil {
|
||||
fs.Debugf(f, "NewObject(%s): %v", remote, err)
|
||||
return nil, fs.ErrorObjectNotFound
|
||||
}
|
||||
|
||||
return f.newPhotosObject(remote, photo, libraryName), nil
|
||||
}
|
||||
|
||||
// newPhotosObject creates a PhotosObject from a Photo and zone
|
||||
func (f *PhotosFs) newPhotosObject(remote string, photo *api.Photo, zone string) *PhotosObject {
|
||||
return &PhotosObject{
|
||||
fs: f,
|
||||
remote: remote,
|
||||
size: photo.Size,
|
||||
modTime: time.UnixMilli(photo.AssetDate),
|
||||
masterID: photo.ID,
|
||||
zone: zone,
|
||||
resourceKey: photo.ResourceKey,
|
||||
width: photo.Width,
|
||||
height: photo.Height,
|
||||
addedDate: photo.AddedDate,
|
||||
isFavorite: photo.IsFavorite,
|
||||
isHidden: photo.IsHidden,
|
||||
}
|
||||
}
|
||||
|
||||
// Put is not supported for this read-only backend
|
||||
func (f *PhotosFs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
|
||||
return nil, fs.ErrorNotImplemented
|
||||
}
|
||||
|
||||
// Mkdir is not supported for this read-only backend
|
||||
func (f *PhotosFs) Mkdir(ctx context.Context, dir string) error {
|
||||
return fs.ErrorNotImplemented
|
||||
}
|
||||
|
||||
// Rmdir is not supported for this read-only backend
|
||||
func (f *PhotosFs) Rmdir(ctx context.Context, dir string) error {
|
||||
return fs.ErrorNotImplemented
|
||||
}
|
||||
|
||||
// FindLeaf finds a directory of name leaf in the folder with ID pathID
|
||||
func (f *PhotosFs) FindLeaf(ctx context.Context, pathID, leaf string) (pathIDOut string, found bool, err error) {
|
||||
decodedLeaf := f.opt.Enc.FromStandardName(leaf)
|
||||
|
||||
photosService, err := f.photosService(ctx)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("failed to get photos service: %w", err)
|
||||
}
|
||||
|
||||
if pathID == rootID {
|
||||
libraries, err := photosService.GetLibraries(ctx)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if _, exists := libraries[decodedLeaf]; exists {
|
||||
return "lib:" + decodedLeaf, true, nil
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(pathID, "lib:") {
|
||||
libraryName := strings.TrimPrefix(pathID, "lib:")
|
||||
libraries, err := photosService.GetLibraries(ctx)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
library, exists := libraries[libraryName]
|
||||
if !exists {
|
||||
return "", false, fs.ErrorDirNotFound
|
||||
}
|
||||
|
||||
albums, err := library.GetAlbums(ctx)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if _, exists := albums[decodedLeaf]; exists {
|
||||
albumID := "album:" + libraryName + ":" + decodedLeaf
|
||||
return albumID, true, nil
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(pathID, "album:") {
|
||||
// Check if this is a folder containing child albums
|
||||
libraryName, albumPath, ok := parseAlbumDirID(pathID)
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
album, err := f.resolveAlbum(ctx, photosService, libraryName, albumPath)
|
||||
if err != nil {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
if album.IsFolder {
|
||||
if _, exists := album.Children[decodedLeaf]; exists {
|
||||
childID := "album:" + libraryName + ":" + albumPath + "/" + decodedLeaf
|
||||
return childID, true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// CreateDir makes a directory with pathID as parent and name leaf
|
||||
func (f *PhotosFs) CreateDir(ctx context.Context, pathID, leaf string) (newID string, err error) {
|
||||
return "", fs.ErrorNotImplemented
|
||||
}
|
||||
|
||||
// Fs returns the parent Fs
|
||||
func (o *PhotosObject) Fs() fs.Info {
|
||||
return o.fs
|
||||
}
|
||||
|
||||
// Return a string version
|
||||
func (o *PhotosObject) String() string {
|
||||
if o == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return o.remote
|
||||
}
|
||||
|
||||
// Remote returns the remote path
|
||||
func (o *PhotosObject) Remote() string {
|
||||
return o.remote
|
||||
}
|
||||
|
||||
// ModTime returns the modification time of the object
|
||||
func (o *PhotosObject) ModTime(ctx context.Context) time.Time {
|
||||
return o.modTime
|
||||
}
|
||||
|
||||
// Size returns the size of an object in bytes
|
||||
func (o *PhotosObject) Size() int64 {
|
||||
return o.size
|
||||
}
|
||||
|
||||
// Storable returns a boolean as to whether this object is storable
|
||||
func (o *PhotosObject) Storable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Hash returns the hash of an object returning a lowercase hex string
|
||||
func (o *PhotosObject) Hash(ctx context.Context, ty hash.Type) (string, error) {
|
||||
return "", hash.ErrUnsupported
|
||||
}
|
||||
|
||||
// SetModTime sets the modification time of the object
|
||||
func (o *PhotosObject) SetModTime(ctx context.Context, modTime time.Time) error {
|
||||
return fs.ErrorCantSetModTime
|
||||
}
|
||||
|
||||
// Metadata returns metadata for the photo object
|
||||
func (o *PhotosObject) Metadata(ctx context.Context) (fs.Metadata, error) {
|
||||
metadata := make(fs.Metadata, 5)
|
||||
if o.width > 0 {
|
||||
metadata["width"] = strconv.Itoa(o.width)
|
||||
}
|
||||
if o.height > 0 {
|
||||
metadata["height"] = strconv.Itoa(o.height)
|
||||
}
|
||||
if o.addedDate > 0 {
|
||||
metadata["added-time"] = time.UnixMilli(o.addedDate).UTC().Format(time.RFC3339)
|
||||
}
|
||||
metadata["favorite"] = strconv.FormatBool(o.isFavorite)
|
||||
metadata["hidden"] = strconv.FormatBool(o.isHidden)
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// Open an object for read, fetching a fresh download URL via records/lookup
|
||||
func (o *PhotosObject) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) {
|
||||
photosService, err := o.fs.photosService(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get photos service: %w", err)
|
||||
}
|
||||
|
||||
downloadURL, err := photosService.LookupDownloadURL(ctx, o.masterID, o.zone, o.resourceKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get download URL for %q: %w", o.remote, err)
|
||||
}
|
||||
|
||||
fs.FixRangeOption(options, o.size)
|
||||
|
||||
var resp *http.Response
|
||||
err = o.fs.pacer.Call(func() (bool, error) {
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
resp = nil
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
fs.OpenOptionAddHTTPHeaders(req.Header, options)
|
||||
|
||||
resp, err = o.fs.httpClient.Do(req)
|
||||
return shouldRetry(ctx, resp, err)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||
_ = resp.Body.Close()
|
||||
return nil, fmt.Errorf("download %q failed: %s", o.remote, resp.Status)
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// Update is not supported for this read-only backend
|
||||
func (o *PhotosObject) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
|
||||
return fs.ErrorNotImplemented
|
||||
}
|
||||
|
||||
// Remove is not supported for this read-only backend
|
||||
func (o *PhotosObject) Remove(ctx context.Context) error {
|
||||
return fs.ErrorNotImplemented
|
||||
}
|
||||
|
||||
// resolveAlbum finds an album by path, traversing folder hierarchy
|
||||
// albumPath can be "AlbumName" or "FolderName/AlbumName" for nested albums
|
||||
func (f *PhotosFs) resolveAlbum(ctx context.Context, ps *api.PhotosService, libraryName, albumPath string) (*api.Album, error) {
|
||||
libraries, err := ps.GetLibraries(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
library, exists := libraries[libraryName]
|
||||
if !exists {
|
||||
return nil, fs.ErrorDirNotFound
|
||||
}
|
||||
|
||||
albums, err := library.GetAlbums(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resolveAlbumPath(albums, albumPath)
|
||||
}
|
||||
|
||||
// resolveAlbumPath finds an album inside an album tree by slash-separated path
|
||||
func resolveAlbumPath(albums map[string]*api.Album, albumPath string) (*api.Album, error) {
|
||||
parts := strings.Split(albumPath, "/")
|
||||
current := albums
|
||||
for i, part := range parts {
|
||||
album, exists := current[part]
|
||||
if !exists {
|
||||
return nil, fs.ErrorDirNotFound
|
||||
}
|
||||
if i == len(parts)-1 {
|
||||
return album, nil
|
||||
}
|
||||
if !album.IsFolder || album.Children == nil {
|
||||
return nil, fs.ErrorDirNotFound
|
||||
}
|
||||
current = album.Children
|
||||
}
|
||||
return nil, fs.ErrorDirNotFound
|
||||
}
|
||||
|
||||
// parseAlbumDirID extracts library and album names from "album:lib:album" dirID
|
||||
func parseAlbumDirID(dirID string) (libraryName, albumName string, ok bool) {
|
||||
parts := strings.SplitN(strings.TrimPrefix(dirID, "album:"), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
// DirCacheFlush resets the directory cache - used in testing as an
|
||||
// optional interface
|
||||
func (f *PhotosFs) DirCacheFlush() {
|
||||
f.dirCache.ResetRoot()
|
||||
// Also flush API-layer caches so albums/photos are re-fetched
|
||||
// DirCacheFlusher interface has no ctx param; use a bounded timeout
|
||||
// to avoid hanging indefinitely if photosService triggers first init
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if ps, err := f.photosService(ctx); err == nil {
|
||||
ps.FlushCaches()
|
||||
}
|
||||
}
|
||||
|
||||
// listRAlbumJob represents an album to be listed by a ListR worker
|
||||
type listRAlbumJob struct {
|
||||
album *api.Album
|
||||
zone string
|
||||
dirPath string // path relative to Fs root (e.g. "Videos" when f.root="PrimarySync")
|
||||
}
|
||||
|
||||
// ListR lists the objects and directories of the Fs starting from dir
|
||||
// recursively into out, calling callback for each batch of entries
|
||||
// Albums are listed in parallel using a goroutine pool
|
||||
func (f *PhotosFs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) error {
|
||||
ps, err := f.photosService(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
helper := list.NewHelper(callback)
|
||||
var mu sync.Mutex // protects helper (not thread-safe)
|
||||
|
||||
addEntry := func(entry fs.DirEntry) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return helper.Add(entry)
|
||||
}
|
||||
|
||||
// Resolve dir through dirCache - this respects f.root
|
||||
dirID, err := f.dirCache.FindDir(ctx, dir, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Collect album jobs, scoped to the resolved directory
|
||||
var jobs []listRAlbumJob
|
||||
|
||||
// collectAlbumJobs emits directory entries for albums/folders under basePath
|
||||
// and collects leaf albums as jobs for parallel photo listing
|
||||
// Recurses into folders to handle arbitrary nesting depth
|
||||
var collectAlbumJobs func(albums map[string]*api.Album, zone, basePath string) error
|
||||
collectAlbumJobs = func(albums map[string]*api.Album, zone, basePath string) error {
|
||||
for albumName, album := range albums {
|
||||
albumPath := path.Join(basePath, f.opt.Enc.ToStandardName(albumName))
|
||||
if err := addEntry(fs.NewDir(albumPath, f.startTime)); err != nil {
|
||||
return err
|
||||
}
|
||||
if album.IsFolder {
|
||||
if err := collectAlbumJobs(album.Children, zone, albumPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
jobs = append(jobs, listRAlbumJob{album: album, zone: zone, dirPath: albumPath})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case dirID == rootID:
|
||||
// Root level - emit libraries, then all albums
|
||||
libraries, err := ps.GetLibraries(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for libName, lib := range libraries {
|
||||
libPath := path.Join(dir, f.opt.Enc.ToStandardName(libName))
|
||||
if err := addEntry(fs.NewDir(libPath, f.startTime)); err != nil {
|
||||
return err
|
||||
}
|
||||
albums, err := lib.GetAlbums(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := collectAlbumJobs(albums, libName, libPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
case strings.HasPrefix(dirID, "lib:"):
|
||||
// Library level - emit and collect all albums
|
||||
libraryName := strings.TrimPrefix(dirID, "lib:")
|
||||
libraries, err := ps.GetLibraries(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
library, exists := libraries[libraryName]
|
||||
if !exists {
|
||||
return fs.ErrorDirNotFound
|
||||
}
|
||||
albums, err := library.GetAlbums(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := collectAlbumJobs(albums, libraryName, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case strings.HasPrefix(dirID, "album:"):
|
||||
// Album or folder level
|
||||
libraryName, albumPath, ok := parseAlbumDirID(dirID)
|
||||
if !ok {
|
||||
return fs.ErrorDirNotFound
|
||||
}
|
||||
album, err := f.resolveAlbum(ctx, ps, libraryName, albumPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if album.IsFolder {
|
||||
if err := collectAlbumJobs(album.Children, libraryName, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Single album - just list its photos
|
||||
jobs = append(jobs, listRAlbumJob{album: album, zone: libraryName, dirPath: dir})
|
||||
}
|
||||
|
||||
default:
|
||||
return fs.ErrorDirNotFound
|
||||
}
|
||||
|
||||
if len(jobs) == 0 {
|
||||
return helper.Flush()
|
||||
}
|
||||
|
||||
// Fan out album photo listing across goroutines
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
jobCh := make(chan listRAlbumJob, len(jobs))
|
||||
for _, job := range jobs {
|
||||
jobCh <- job
|
||||
}
|
||||
close(jobCh)
|
||||
|
||||
workers := fs.GetConfig(ctx).Checkers
|
||||
if len(jobs) < workers {
|
||||
workers = len(jobs)
|
||||
}
|
||||
errs := make(chan error, workers)
|
||||
|
||||
for range workers {
|
||||
go func() {
|
||||
for job := range jobCh {
|
||||
photos, err := job.album.GetPhotos(ctx)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
for _, photo := range photos {
|
||||
if photo.Filename == "" {
|
||||
continue
|
||||
}
|
||||
remotePath := path.Join(job.dirPath, f.opt.Enc.ToStandardName(photo.Filename))
|
||||
o := f.newPhotosObject(remotePath, photo, job.zone)
|
||||
if err := addEntry(o); err != nil {
|
||||
errs <- err
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
errs <- nil
|
||||
}()
|
||||
}
|
||||
|
||||
for range workers {
|
||||
if e := <-errs; e != nil && err == nil {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return helper.Flush()
|
||||
}
|
||||
|
||||
func notifyAlbumTree(albums map[string]*api.Album, base string, notifyFunc func(string, fs.EntryType)) {
|
||||
for name, album := range albums {
|
||||
albumPath := name
|
||||
if base != "" {
|
||||
albumPath = path.Join(base, name)
|
||||
}
|
||||
notifyFunc(albumPath, fs.EntryDirectory)
|
||||
if album.IsFolder {
|
||||
notifyAlbumTree(album.Children, albumPath, notifyFunc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *PhotosFs) notifyZoneChange(ctx context.Context, ps *api.PhotosService, zone string, notifyFunc func(string, fs.EntryType)) {
|
||||
if f.root == "" {
|
||||
notifyFunc(zone, fs.EntryDirectory)
|
||||
return
|
||||
}
|
||||
|
||||
libraryName, albumPath, _ := strings.Cut(f.root, "/")
|
||||
if libraryName != zone {
|
||||
return
|
||||
}
|
||||
|
||||
// Always invalidate the mounted root when its backing zone changes
|
||||
notifyFunc("", fs.EntryDirectory)
|
||||
|
||||
libraries, err := ps.GetLibraries(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
library, ok := libraries[zone]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
albums, err := library.GetAlbums(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if albumPath == "" {
|
||||
notifyAlbumTree(albums, "", notifyFunc)
|
||||
return
|
||||
}
|
||||
|
||||
album, err := resolveAlbumPath(albums, albumPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if album.IsFolder {
|
||||
notifyAlbumTree(album.Children, "", notifyFunc)
|
||||
}
|
||||
}
|
||||
|
||||
// ChangeNotify polls for changes and notifies the VFS when directories are modified
|
||||
func (f *PhotosFs) ChangeNotify(ctx context.Context, notifyFunc func(string, fs.EntryType), pollIntervalChan <-chan time.Duration) {
|
||||
go func() {
|
||||
var ticker *time.Ticker
|
||||
var tickerC <-chan time.Time
|
||||
for {
|
||||
select {
|
||||
case pollInterval, ok := <-pollIntervalChan:
|
||||
if !ok {
|
||||
if ticker != nil {
|
||||
ticker.Stop()
|
||||
}
|
||||
return
|
||||
}
|
||||
if ticker != nil {
|
||||
ticker.Stop()
|
||||
tickerC = nil
|
||||
}
|
||||
if pollInterval > 0 {
|
||||
ticker = time.NewTicker(pollInterval)
|
||||
tickerC = ticker.C
|
||||
}
|
||||
case <-tickerC:
|
||||
ps, err := f.photosService(ctx)
|
||||
if err != nil {
|
||||
fs.Debugf(f, "ChangeNotify: failed to get photos service: %v", err)
|
||||
continue
|
||||
}
|
||||
changedZones := ps.PollForChanges(ctx)
|
||||
for _, zone := range changedZones {
|
||||
f.notifyZoneChange(ctx, ps, zone, notifyFunc)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
if ticker != nil {
|
||||
ticker.Stop()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Disconnect clears authentication state and removes disk caches
|
||||
func (f *PhotosFs) Disconnect(ctx context.Context) error {
|
||||
return disconnectClient(f.m, f.icloud)
|
||||
}
|
||||
|
||||
// Check interfaces are satisfied
|
||||
var (
|
||||
_ fs.Fs = (*PhotosFs)(nil)
|
||||
_ fs.Disconnecter = (*PhotosFs)(nil)
|
||||
_ fs.ListRer = (*PhotosFs)(nil)
|
||||
_ fs.ChangeNotifier = (*PhotosFs)(nil)
|
||||
_ fs.DirCacheFlusher = (*PhotosFs)(nil)
|
||||
_ fs.Object = (*PhotosObject)(nil)
|
||||
_ fs.Metadataer = (*PhotosObject)(nil)
|
||||
)
|
||||
Reference in New Issue
Block a user