Repository: rclone/rclone
Pull request #7805 (2024-04-25 13:51:52)

Added content-length header when html file is served
#### What is the purpose of this change?

Add the `Content-Length` to the html response when `http serve http ...` is executed

#### Was the change discussed in an issue or in the forum before?

NA

#### Checklist

- [x] I have read the [contribution guidelines](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#submitting-a-new-feature-or-bug-fix).
- [ ] I have added tests for all changes in this PR if appropriate.
- [ ] I have added documentation for the changes if appropriate.
- [x] All commit messages are in [house style](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#commit-messages).
- [x] I'm done, this Pull Request is ready for review :-)



Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3a939f19d..0a1413f74 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -223,24 +223,42 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
+      - name: Get runner parameters
+        id: get-runner-parameters
+        shell: bash
+        run: |
+          echo "year-week=$(/bin/date -u "+%Y%V")" >> $GITHUB_OUTPUT
+          echo "runner-os-version=$ImageOS" >> $GITHUB_OUTPUT
+
       - name: Checkout
         uses: actions/checkout@v4
 
       - name: Install Go
+        id: setup-go
         uses: actions/setup-go@v5
         with:
           go-version: '>=1.22.0-rc.1'
           check-latest: true
           cache: false
 
+      - name: Cache
+        uses: actions/cache@v4
+        with:
+          path: |
+            ~/go/pkg/mod
+            ~/.cache/go-build
+            ~/.cache/golangci-lint
+          key: golangci-lint-${{ steps.get-runner-parameters.outputs.runner-os-version }}-go${{ steps.setup-go.outputs.go-version }}-${{ steps.get-runner-parameters.outputs.year-week }}-${{ hashFiles('go.sum') }}
+          restore-keys: golangci-lint-${{ steps.get-runner-parameters.outputs.runner-os-version }}-go${{ steps.setup-go.outputs.go-version }}-${{ steps.get-runner-parameters.outputs.year-week }}-
+
       - name: Code quality test (Linux)
-        uses: golangci/golangci-lint-action@v4
+        uses: golangci/golangci-lint-action@v6
         with:
           version: latest
-          skip-cache: false # Caching enabled (which is default) on this first lint step only, it handles complete cache of build, go modules and golangci-lint analysis which was necessary to get all lint steps to properly take advantage of it
+          skip-cache: true
 
       - name: Code quality test (Windows)
-        uses: golangci/golangci-lint-action@v4
+        uses: golangci/golangci-lint-action@v6
         env:
           GOOS: "windows"
         with:
@@ -248,7 +266,7 @@ jobs:
           skip-cache: true
 
       - name: Code quality test (macOS)
-        uses: golangci/golangci-lint-action@v4
+        uses: golangci/golangci-lint-action@v6
         env:
           GOOS: "darwin"
         with:
@@ -256,7 +274,7 @@ jobs:
           skip-cache: true
 
       - name: Code quality test (FreeBSD)
-        uses: golangci/golangci-lint-action@v4
+        uses: golangci/golangci-lint-action@v6
         env:
           GOOS: "freebsd"
         with:
@@ -264,7 +282,7 @@ jobs:
           skip-cache: true
 
       - name: Code quality test (OpenBSD)
-        uses: golangci/golangci-lint-action@v4
+        uses: golangci/golangci-lint-action@v6
         env:
           GOOS: "openbsd"
         with:
diff --git a/.github/workflows/notify.yml b/.github/workflows/notify.yml
new file mode 100644
index 000000000..2aa623d13
--- /dev/null
+++ b/.github/workflows/notify.yml
@@ -0,0 +1,14 @@
+name: Notify users based on issue labels
+
+on:
+  issues:
+      types: [labeled]
+
+jobs:
+  notify:
+    runs-on: ubuntu-latest
+    steps:
+        - uses: jenschelkopf/issue-label-notification-action@1.3
+          with:
+             recipients: |
+                  Support Contract=@rclone/support
diff --git a/Makefile b/Makefile
index 3503a91fb..2e5aceba2 100644
--- a/Makefile
+++ b/Makefile
@@ -36,7 +36,7 @@ ifdef BETA_SUBDIR
 endif
 BETA_PATH := $(BRANCH_PATH)$(TAG)$(BETA_SUBDIR)
 BETA_URL := https://beta.rclone.org/$(BETA_PATH)/
-BETA_UPLOAD_ROOT := memstore:beta-rclone-org
+BETA_UPLOAD_ROOT := beta.rclone.org:
 BETA_UPLOAD := $(BETA_UPLOAD_ROOT)/$(BETA_PATH)
 # Pass in GOTAGS=xyz on the make command line to set build tags
 ifdef GOTAGS
@@ -167,7 +167,7 @@ website:
 	@if grep -R "raw HTML omitted" docs/public ; then echo "ERROR: found unescaped HTML - fix the markdown source" ; fi
 
 upload_website:	website
-	rclone -v sync docs/public memstore:www-rclone-org
+	rclone -v sync docs/public www.rclone.org:
 
 upload_test_website:	website
 	rclone -P sync docs/public test-rclone-org:
@@ -194,8 +194,8 @@ check_sign:
 	cd build && gpg --verify SHA256SUMS && gpg --decrypt SHA256SUMS | sha256sum -c
 
 upload:
-	rclone -P copy build/ memstore:downloads-rclone-org/$(TAG)
-	rclone lsf build --files-only --include '*.{zip,deb,rpm}' --include version.txt | xargs -i bash -c 'i={}; j="$$i"; [[ $$i =~ (.*)(-v[0-9\.]+-)(.*) ]] && j=$${BASH_REMATCH[1]}-current-$${BASH_REMATCH[3]}; rclone copyto -v "memstore:downloads-rclone-org/$(TAG)/$$i" "memstore:downloads-rclone-org/$$j"'
+	rclone -P copy build/ downloads.rclone.org:/$(TAG)
+	rclone lsf build --files-only --include '*.{zip,deb,rpm}' --include version.txt | xargs -i bash -c 'i={}; j="$$i"; [[ $$i =~ (.*)(-v[0-9\.]+-)(.*) ]] && j=$${BASH_REMATCH[1]}-current-$${BASH_REMATCH[3]}; rclone copyto -v "downloads.rclone.org:/$(TAG)/$$i" "downloads.rclone.org:/$$j"'
 
 upload_github:
 	./bin/upload-github $(TAG)
@@ -205,7 +205,7 @@ cross:	doc
 
 beta:
 	go run bin/cross-compile.go $(BUILD_FLAGS) $(BUILDTAGS) $(BUILD_ARGS) $(TAG)
-	rclone -v copy build/ memstore:pub-rclone-org/$(TAG)
+	rclone -v copy build/ pub.rclone.org:/$(TAG)
 	@echo Beta release ready at https://pub.rclone.org/$(TAG)/
 
 log_since_last_release:
@@ -218,18 +218,18 @@ ci_upload:
 	sudo chown -R $$USER build
 	find build -type l -delete
 	gzip -r9v build
-	./rclone --config bin/travis.rclone.conf -v copy build/ $(BETA_UPLOAD)/testbuilds
+	./rclone --no-check-dest --config bin/ci.rclone.conf -v copy build/ $(BETA_UPLOAD)/testbuilds
 ifeq ($(or $(BRANCH_PATH),$(RELEASE_TAG)),)
-	./rclone --config bin/travis.rclone.conf -v copy build/ $(BETA_UPLOAD_ROOT)/test/testbuilds-latest
+	./rclone --no-check-dest --config bin/ci.rclone.conf -v copy build/ $(BETA_UPLOAD_ROOT)/test/testbuilds-latest
 endif
 	@echo Beta release ready at $(BETA_URL)/testbuilds
 
 ci_beta:
 	git log $(LAST_TAG).. > /tmp/git-log.txt
 	go run bin/cross-compile.go -release beta-latest -git-log /tmp/git-log.txt $(BUILD_FLAGS) $(BUILDTAGS) $(BUILD_ARGS) $(TAG)
-	rclone --config bin/travis.rclone.conf -v copy --exclude '*beta-latest*' build/ $(BETA_UPLOAD)
+	rclone --no-check-dest --config bin/ci.rclone.conf -v copy --exclude '*beta-latest*' build/ $(BETA_UPLOAD)
 ifeq ($(or $(BRANCH_PATH),$(RELEASE_TAG)),)
-	rclone --config bin/travis.rclone.conf -v copy --include '*beta-latest*' --include version.txt build/ $(BETA_UPLOAD_ROOT)$(BETA_SUBDIR)
+	rclone --no-check-dest --config bin/ci.rclone.conf -v copy --include '*beta-latest*' --include version.txt build/ $(BETA_UPLOAD_ROOT)$(BETA_SUBDIR)
 endif
 	@echo Beta release ready at $(BETA_URL)
 
diff --git a/backend/drive/drive.go b/backend/drive/drive.go
index 0e97d9553..1f0afcdc4 100644
--- a/backend/drive/drive.go
+++ b/backend/drive/drive.go
@@ -1747,10 +1747,9 @@ func (f *Fs) createDir(ctx context.Context, pathID, leaf string, metadata fs.Met
 	leaf = f.opt.Enc.FromStandardName(leaf)
 	pathID = actualID(pathID)
 	createInfo := &drive.File{
-		Name:        leaf,
-		Description: leaf,
-		MimeType:    driveFolderType,
-		Parents:     []string{pathID},
+		Name:     leaf,
+		MimeType: driveFolderType,
+		Parents:  []string{pathID},
 	}
 	var updateMetadata updateMetadataFn
 	if len(metadata) > 0 {
@@ -2430,7 +2429,6 @@ func (f *Fs) createFileInfo(ctx context.Context, remote string, modTime time.Tim
 	// Define the metadata for the file we are going to create.
 	createInfo := &drive.File{
 		Name:         leaf,
-		Description:  leaf,
 		Parents:      []string{directoryID},
 		ModifiedTime: modTime.Format(timeFormatOut),
 	}
diff --git a/backend/drive/metadata.go b/backend/drive/metadata.go
index ee547eed0..a79dac3af 100644
--- a/backend/drive/metadata.go
+++ b/backend/drive/metadata.go
@@ -9,6 +9,7 @@ import (
 	"sync"
 
 	"github.com/rclone/rclone/fs"
+	"github.com/rclone/rclone/fs/fserrors"
 	"github.com/rclone/rclone/lib/errcount"
 	"golang.org/x/sync/errgroup"
 	drive "google.golang.org/api/drive/v3"
@@ -151,11 +152,15 @@ func (f *Fs) setPermissions(ctx context.Context, info *drive.File, permissions [
 			return f.shouldRetry(ctx, err)
 		})
 		if err != nil {
-			fs.Errorf(f, "Failed to set permission: %v", err)
+			fs.Errorf(f, "Failed to set permission %s for %q: %v", perm.Role, perm.EmailAddress, err)
 			errs.Add(err)
 		}
 	}
-	return errs.Err("failed to set permission")
+	err = errs.Err("failed to set permission")
+	if err != nil {
+		err = fserrors.NoRetryError(err)
+	}
+	return err
 }
 
 // Clean attributes from permissions which we can't write
diff --git a/backend/onedrive/api/types.go b/backend/onedrive/api/types.go
index da4fa5a3c..0332f1c12 100644
--- a/backend/onedrive/api/types.go
+++ b/backend/onedrive/api/types.go
@@ -42,6 +42,8 @@ var _ error = (*Error)(nil)
 type Identity struct {
 	DisplayName string `json:"displayName,omitempty"`
 	ID          string `json:"id,omitempty"`
+	Email       string `json:"email,omitempty"`     // not officially documented, but seems to sometimes exist
+	LoginName   string `json:"loginName,omitempty"` // SharePoint only
 }
 
 // IdentitySet is a keyed collection of Identity objects. It is used
@@ -51,6 +53,9 @@ type IdentitySet struct {
 	User        Identity `json:"user,omitempty"`
 	Application Identity `json:"application,omitempty"`
 	Device      Identity `json:"device,omitempty"`
+	Group       Identity `json:"group,omitempty"`
+	SiteGroup   Identity `json:"siteGroup,omitempty"` // The SharePoint group associated with this action. Optional.
+	SiteUser    Identity `json:"siteUser,omitempty"`  // The SharePoint user associated with this action. Optional.
 }
 
 // Quota groups storage space quota-related information on OneDrive into a single structure.
@@ -215,14 +220,16 @@ const (
 // PermissionsType provides information about a sharing permission granted for a DriveItem resource.
 // Sharing permissions have a number of different forms. The Permission resource represents these different forms through facets on the resource.
 type PermissionsType struct {
-	ID                  string                 `json:"id"`                            // The unique identifier of the permission among all permissions on the item. Read-only.
-	GrantedTo           *IdentitySet           `json:"grantedTo,omitempty"`           // For user type permissions, the details of the users & applications for this permission. Read-only.
-	GrantedToIdentities []*IdentitySet         `json:"grantedToIdentities,omitempty"` // For link type permissions, the details of the users to whom permission was granted. Read-only.
-	Invitation          *SharingInvitationType `json:"invitation,omitempty"`          // Details of any associated sharing invitation for this permission. Read-only.
-	InheritedFrom       *ItemReference         `json:"inheritedFrom,omitempty"`       // Provides a reference to the ancestor of the current permission, if it is inherited from an ancestor. Read-only.
-	Link                *SharingLinkType       `json:"link,omitempty"`                // Provides the link details of the current permission, if it is a link type permissions. Read-only.
-	Roles               []Role                 `json:"roles,omitempty"`               // The type of permission (read, write, owner, member). Read-only.
-	ShareID             string                 `json:"shareId,omitempty"`             // A unique token that can be used to access this shared item via the shares API. Read-only.
+	ID                    string                 `json:"id"`                              // The unique identifier of the permission among all permissions on the item. Read-only.
+	GrantedTo             *IdentitySet           `json:"grantedTo,omitempty"`             // For user type permissions, the details of the users & applications for this permission. Read-only. Deprecated on OneDrive Business only.
+	GrantedToIdentities   []*IdentitySet         `json:"grantedToIdentities,omitempty"`   // For link type permissions, the details of the users to whom permission was granted. Read-only. Deprecated on OneDrive Business only.
+	GrantedToV2           *IdentitySet           `json:"grantedToV2,omitempty"`           // For user type permissions, the details of the users & applications for this permission. Read-only. Not available for OneDrive Personal.
+	GrantedToIdentitiesV2 []*IdentitySet         `json:"grantedToIdentitiesV2,omitempty"` // For link type permissions, the details of the users to whom permission was granted. Read-only. Not available for OneDrive Personal.
+	Invitation            *SharingInvitationType `json:"invitation,omitempty"`            // Details of any associated sharing invitation for this permission. Read-only.
+	InheritedFrom         *ItemReference         `json:"inheritedFrom,omitempty"`         // Provides a reference to the ancestor of the current permission, if it is inherited from an ancestor. Read-only.
+	Link                  *SharingLinkType       `json:"link,omitempty"`                  // Provides the link details of the current permission, if it is a link type permissions. Read-only.
+	Roles                 []Role                 `json:"roles,omitempty"`                 // The type of permission (read, write, owner, member). Read-only.
+	ShareID               string                 `json:"shareId,omitempty"`               // A unique token that can be used to access this shared item via the shares API. Read-only.
 }
 
 // Role represents the type of permission (read, write, owner, member)
@@ -592,3 +599,25 @@ type SiteResource struct {
 type SiteResponse struct {
 	Sites []SiteResource `json:"value"`
 }
+
+// GetGrantedTo returns the GrantedTo property.
+// This is to get around the odd problem of
+// GrantedTo being deprecated on OneDrive Business, while
+// GrantedToV2 is unavailable on OneDrive Personal.
+func (p *PermissionsType) GetGrantedTo(driveType string) *IdentitySet {
+	if driveType == "personal" {
+		return p.GrantedTo
+	}
+	return p.GrantedToV2
+}
+
+// GetGrantedToIdentities returns the GrantedToIdentities property.
+// This is to get around the odd problem of
+// GrantedToIdentities being deprecated on OneDrive Business, while
+// GrantedToIdentitiesV2 is unavailable on OneDrive Personal.
+func (p *PermissionsType) GetGrantedToIdentities(driveType string) []*IdentitySet {
+	if driveType == "personal" {
+		return p.GrantedToIdentities
+	}
+	return p.GrantedToIdentitiesV2
+}
diff --git a/backend/onedrive/metadata.go b/backend/onedrive/metadata.go
index 30fad0359..b94dcb974 100644
--- a/backend/onedrive/metadata.go
+++ b/backend/onedrive/metadata.go
@@ -11,6 +11,7 @@ import (
 
 	"github.com/rclone/rclone/backend/onedrive/api"
 	"github.com/rclone/rclone/fs"
+	"github.com/rclone/rclone/fs/fserrors"
 	"github.com/rclone/rclone/lib/dircache"
 	"github.com/rclone/rclone/lib/errcount"
 	"golang.org/x/exp/slices" // replace with slices after go1.21 is the minimum version
@@ -395,7 +396,7 @@ func (m *Metadata) sortPermissions() (add, update, remove []*api.PermissionsType
 		if n.ID != "" {
 			// sanity check: ensure there's a matching "old" id with a non-matching role
 			if !slices.ContainsFunc(old, func(o *api.PermissionsType) bool {
-				return o.ID == n.ID && slices.Compare(o.Roles, n.Roles) != 0 && len(o.Roles) > 0 && len(n.Roles) > 0
+				return o.ID == n.ID && slices.Compare(o.Roles, n.Roles) != 0 && len(o.Roles) > 0 && len(n.Roles) > 0 && !slices.Contains(o.Roles, api.OwnerRole)
 			}) {
 				fs.Debugf(m.remote, "skipping update for invalid roles: %v (perm ID: %v)", n.Roles, n.ID)
 				continue
@@ -417,6 +418,10 @@ func (m *Metadata) sortPermissions() (add, update, remove []*api.PermissionsType
 		}
 	}
 	for _, o := range old {
+		if slices.Contains(o.Roles, api.OwnerRole) {
+			fs.Debugf(m.remote, "skipping remove permission -- can't remove 'owner' role")
+			continue
+		}
 		newHasOld := slices.ContainsFunc(new, func(n *api.PermissionsType) bool {
 			if n == nil || n.ID == "" {
 				return false // can't remove perms without an ID
@@ -462,17 +467,21 @@ func (m *Metadata) processPermissions(ctx context.Context, add, update, remove [
 		newPermissions = append(newPermissions, newP)
 	}
 
-	return newPermissions, errs.Err("failed to set permissions")
+	err = errs.Err("failed to set permissions")
+	if err != nil {
+		err = fserrors.NoRetryError(err)
+	}
+	return newPermissions, err
 }
 
 // fillRecipients looks for recipients to add from the permission passed in.
-// It looks for an email address in identity.User.ID and DisplayName, otherwise it uses the identity.User.ID as r.ObjectID.
+// It looks for an email address in identity.User.Email, ID, and DisplayName, otherwise it uses the identity.User.ID as r.ObjectID.
 // It considers both "GrantedTo" and "GrantedToIdentities".
-func fillRecipients(p *api.PermissionsType) (recipients []api.DriveRecipient) {
+func fillRecipients(p *api.PermissionsType, driveType string) (recipients []api.DriveRecipient) {
 	if p == nil {
 		return recipients
 	}
-	ids := make(map[string]struct{}, len(p.GrantedToIdentities)+1)
+	ids := make(map[string]struct{}, len(p.GetGrantedToIdentities(driveType))+1)
 	isUnique := func(s string) bool {
 		_, ok := ids[s]
 		return !ok && s != ""
@@ -482,7 +491,10 @@ func fillRecipients(p *api.PermissionsType) (recipients []api.DriveRecipient) {
 		r := api.DriveRecipient{}
 
 		id := ""
-		if strings.ContainsRune(identity.User.ID, '@') {
+		if strings.ContainsRune(identity.User.Email, '@') {
+			id = identity.User.Email
+			r.Email = id
+		} else if strings.ContainsRune(identity.User.ID, '@') {
 			id = identity.User.ID
 			r.Email = id
 		} else if strings.ContainsRune(identity.User.DisplayName, '@') {
@@ -498,12 +510,31 @@ func fillRecipients(p *api.PermissionsType) (recipients []api.DriveRecipient) {
 		ids[id] = struct{}{}
 		recipients = append(recipients, r)
 	}
-	for _, identity := range p.GrantedToIdentities {
-		addRecipient(identity)
+
+	forIdentitySet := func(iSet *api.IdentitySet) {
+		if iSet == nil {
+			return
+		}
+		iS := *iSet
+		forIdentity := func(i api.Identity) {
+			if i != (api.Identity{}) {
+				iS.User = i
+				addRecipient(&iS)
+			}
+		}
+		forIdentity(iS.User)
+		forIdentity(iS.SiteUser)
+		forIdentity(iS.Group)
+		forIdentity(iS.SiteGroup)
+		forIdentity(iS.Application)
+		forIdentity(iS.Device)
 	}
-	if p.GrantedTo != nil && p.GrantedTo.User != (api.Identity{}) {
-		addRecipient(p.GrantedTo)
+
+	for _, identitySet := range p.GetGrantedToIdentities(driveType) {
+		forIdentitySet(identitySet)
 	}
+	forIdentitySet(p.GetGrantedTo(driveType))
+
 	return recipients
 }
 
@@ -513,7 +544,7 @@ func (m *Metadata) addPermission(ctx context.Context, p *api.PermissionsType) (n
 	opts := m.fs.newOptsCall(m.normalizedID, "POST", "/invite")
 
 	req := &api.AddPermissionsRequest{
-		Recipients:    fillRecipients(p),
+		Recipients:    fillRecipients(p, m.fs.driveType),
 		RequireSignIn: m.fs.driveType != driveTypePersonal, // personal and business have conflicting requirements
 		Roles:         p.Roles,
 	}
diff --git a/backend/onedrive/metadata.md b/backend/onedrive/metadata.md
index bd057690a..1af680760 100644
--- a/backend/onedrive/metadata.md
+++ b/backend/onedrive/metadata.md
@@ -109,7 +109,8 @@ To update an existing permission, include both the Permission ID and the new
 `roles` to be assigned. `roles` is the only property that can be changed.
 
 To remove permissions, pass in a blob containing only the permissions you wish
-to keep (which can be empty, to remove all.)
+to keep (which can be empty, to remove all.) Note that the `owner` role will be
+ignored, as it cannot be removed.
 
 Note that both reading and writing permissions requires extra API calls, so if
 you don't need to read or write permissions it is recommended to omit
diff --git a/backend/pikpak/api/types.go b/backend/pikpak/api/types.go
index 92c81d426..8b2270e2a 100644
--- a/backend/pikpak/api/types.go
+++ b/backend/pikpak/api/types.go
@@ -53,7 +53,7 @@ const (
 	PhaseTypePending    = "PHASE_TYPE_PENDING"
 	UploadTypeForm      = "UPLOAD_TYPE_FORM"
 	UploadTypeResumable = "UPLOAD_TYPE_RESUMABLE"
-	ListLimit           = 100
+	ListLimit           = 500
 )
 
 // ------------------------------------------------------------
@@ -156,6 +156,7 @@ type FileList struct {
 	NextPageToken   string  `json:"next_page_token"`
 	Version         string  `json:"version,omitempty"`
 	VersionOutdated bool    `json:"version_outdated,omitempty"`
+	SyncTime        Time    `json:"sync_time"`
 }
 
 // File is a basic element representing a single file object
@@ -165,14 +166,14 @@ type FileList struct {
 // 2) the other from File.Medias[].Link.URL.
 // Empirically, 2) is less restrictive to multiple concurrent range-requests
 // for a single file, i.e. supports for higher `--multi-thread-streams=N`.
-// However, it is not generally applicable as it is only for meadia.
+// However, it is not generally applicable as it is only for media.
 type File struct {
 	Apps              []*FileApp    `json:"apps,omitempty"`
 	Audit             *FileAudit    `json:"audit,omitempty"`
 	Collection        string        `json:"collection,omitempty"` // TODO
 	CreatedTime       Time          `json:"created_time,omitempty"`
 	DeleteTime        Time          `json:"delete_time,omitempty"`
-	FileCategory      string        `json:"file_category,omitempty"`
+	FileCategory      string        `json:"file_category,omitempty"` // "AUDIO", "VIDEO"
 	FileExtension     string        `json:"file_extension,omitempty"`
 	FolderType        string        `json:"folder_type,omitempty"`
 	Hash              string        `json:"hash,omitempty"` // sha1 but NOT a valid file hash. looks like a torrent hash
@@ -191,11 +192,14 @@ type File struct {
 	ParentID          string        `json:"parent_id,omitempty"`
 	Phase             string        `json:"phase,omitempty"`
 	Revision          int           `json:"revision,omitempty,string"`
+	ReferenceEvents   []interface{} `json:"reference_events"`
+	ReferenceResource interface{}   `json:"reference_resource"`
 	Size              int64         `json:"size,omitempty,string"`
 	SortName          string        `json:"sort_name,omitempty"`
 	Space             string        `json:"space,omitempty"`
 	SpellName         []interface{} `json:"spell_name,omitempty"` // TODO maybe list of something?
 	Starred           bool          `json:"starred,omitempty"`
+	Tags              []interface{} `json:"tags"`
 	ThumbnailLink     string        `json:"thumbnail_link,omitempty"`
 	Trashed           bool          `json:"trashed,omitempty"`
 	UserID            string        `json:"user_id,omitempty"`
@@ -241,7 +245,8 @@ type Media struct {
 	IsOrigin       bool          `json:"is_origin,omitempty"`
 	ResolutionName string        `json:"resolution_name,omitempty"`
 	IsVisible      bool          `json:"is_visible,omitempty"`
-	Category       string        `json:"category,omitempty"`
+	Category       string        `json:"category,omitempty"` // "category_origin"
+	Audio          interface{}   `json:"audio"`              // TODO: undiscovered yet
 }
 
 // FileParams includes parameters for instant open
@@ -395,6 +400,7 @@ type Quota struct {
 	UsageInTrash   int64  `json:"usage_in_trash,omitempty,string"` // bytes in trash but this seems not working
 	PlayTimesLimit string `json:"play_times_limit,omitempty"`      // maybe in seconds
 	PlayTimesUsage string `json:"play_times_usage,omitempty"`      // maybe in seconds
+	IsUnlimited    bool   `json:"is_unlimited,omitempty"`
 }
 
 // Share is a response to RequestShare
diff --git a/backend/pikpak/pikpak.go b/backend/pikpak/pikpak.go
index 07fc0b201..008180356 100644
--- a/backend/pikpak/pikpak.go
+++ b/backend/pikpak/pikpak.go
@@ -1016,21 +1016,24 @@ func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object,
 	}
 	dstObj.id = srcObj.id
 
-	var info *api.File
 	if srcLeaf != dstLeaf {
 		// Rename
-		info, err = f.renameObject(ctx, srcObj.id, dstLeaf)
+		info, err := f.renameObject(ctx, srcObj.id, dstLeaf)
 		if err != nil {
 			return nil, fmt.Errorf("move: couldn't rename moved file: %w", err)
 		}
+		err = dstObj.setMetaData(info)
+		if err != nil {
+			return nil, err
+		}
 	} else {
 		// Update info
-		info, err = f.getFile(ctx, dstObj.id)
+		err = dstObj.readMetaData(ctx)
 		if err != nil {
-			return nil, fmt.Errorf("move: couldn't update moved file: %w", err)
+			return nil, fmt.Errorf("move: couldn't locate moved file: %w", err)
 		}
 	}
-	return dstObj, dstObj.setMetaData(info)
+	return dstObj, nil
 }
 
 // copy objects
@@ -1143,7 +1146,7 @@ func (f *Fs) uploadByForm(ctx context.Context, in io.Reader, name string, size i
 	return
 }
 
-func (f *Fs) uploadByResumable(ctx context.Context, in io.Reader, resumable *api.Resumable, options ...fs.OpenOption) (err error) {
+func (f *Fs) uploadByResumable(ctx context.Context, in io.Reader, resumable *api.Resumable) (err error) {
 	p := resumable.Params
 	endpoint := strings.Join(strings.Split(p.Endpoint, ".")[1:], ".") // "mypikpak.com"
 
@@ -1206,7 +1209,7 @@ func (f *Fs) upload(ctx context.Context, in io.Reader, leaf, dirID, sha1Str stri
 	if uploadType == api.UploadTypeForm && newfile.Form != nil {
 		err = f.uploadByForm(ctx, in, req.Name, size, newfile.Form, options...)
 	} else if uploadType == api.UploadTypeResumable && newfile.Resumable != nil {
-		err = f.uploadByResumable(ctx, in, newfile.Resumable, options...)
+		err = f.uploadByResumable(ctx, in, newfile.Resumable)
 	} else {
 		return nil, fmt.Errorf("unable to proceed upload: %+v", newfile)
 	}
@@ -1214,10 +1217,7 @@ func (f *Fs) upload(ctx context.Context, in io.Reader, leaf, dirID, sha1Str stri
 	if err != nil {
 		return nil, fmt.Errorf("failed to upload: %w", err)
 	}
-	// refresh uploaded file info
-	// Compared to `newfile.File` this upgrades several fields...
-	// audit, links, modified_time, phase, revision, and web_content_link
-	return f.getFile(ctx, newfile.File.ID)
+	return newfile.File, nil
 }
 
 // Put the object
diff --git a/backend/ulozto/ulozto.go b/backend/ulozto/ulozto.go
index 063f465db..1cdeefd74 100644
--- a/backend/ulozto/ulozto.go
+++ b/backend/ulozto/ulozto.go
@@ -121,6 +121,9 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 		return nil, err
 	}
 
+	// Strip leading and trailing slashes, see https://github.com/rclone/rclone/issues/7796 for details.
+	root = strings.Trim(root, "/")
+
 	client := fshttp.NewClient(ctx)
 
 	f := &Fs{
diff --git a/backend/union/union.go b/backend/union/union.go
index 833ed1838..3f1d51d16 100644
--- a/backend/union/union.go
+++ b/backend/union/union.go
@@ -127,6 +127,11 @@ func (f *Fs) Features() *fs.Features {
 func (f *Fs) Rmdir(ctx context.Context, dir string) error {
 	upstreams, err := f.action(ctx, dir)
 	if err != nil {
+		// If none of the backends can have empty directories then
+		// don't complain about directories not being found
+		if !f.features.CanHaveEmptyDirectories && err == fs.ErrorObjectNotFound {
+			return nil
+		}
 		return err
 	}
 	errs := Errors(make([]error, len(upstreams)))
diff --git a/bin/ci.rclone.conf b/bin/ci.rclone.conf
new file mode 100644
index 000000000..3dca5df18
--- /dev/null
+++ b/bin/ci.rclone.conf
@@ -0,0 +1,4 @@
+# Encrypted rclone configuration File
+
+RCLONE_ENCRYPT_V0:
+H/3G07cjuVyWvg8Ce4dkNzXDRAr37SBHa8cxkPQFtfwKOFDkEmSecuCYun0j/0XtQl9EluGssBMgdztL9sErIsber+QFi27VUWTCXaLnhj940ylSbjI7DnOXtnQGi+WWjdWlW4e8IEbVg44h1Z/iU4D0pyMzMLaEpGHUtnhab+/dBNI5QTtXS+2W1kvDau0C/C3CJULoyMBGC63NMIFl1ks15j67CyiGspCGGbGqo9UlaoMvnK1YlMQX0zIDD4YeNU8M8puxteVHWX6MKGCsd2xY1l7hd47DDIQ1cDIFI5oJWSE2lFGhUs3F5xU4gIV9dOQHrsKom7PCwKaEfLXBJToBMLKphMtElkucEb7DTDdDGNyOyOQ8GSwbkDnsBWydE5S0YQEAt893kZ+QisQsWqfNBcKOLatbuUyq4RBvuNxyyXzC9sVYdA2qgJ+BlZvEihd76i/ZwXnyVpQc+LemgGYFjz0QkVfAd0X63oUkn5GGMpw8eL0w7LcIUan4z//x3jUUwV5muGegC4LUWTBBkgDnkrzuAQ==
\ No newline at end of file
diff --git a/bin/tidy-beta b/bin/tidy-beta
index 9d5d29fad..bbcc7234f 100755
--- a/bin/tidy-beta
+++ b/bin/tidy-beta
@@ -18,4 +18,4 @@ rclone ${dry_run} -vv -P --checkers 16 --transfers 16 delete \
        --fast-list \
        --include "/${version}**" \
        --include "/branch/*/${version}**" \
-       memstore:beta-rclone-org
+       beta.rclone.org:
diff --git a/bin/travis.rclone.conf b/bin/travis.rclone.conf
deleted file mode 100644
index 597253c1a..000000000
--- a/bin/travis.rclone.conf
+++ /dev/null
@@ -1,4 +0,0 @@
-# Encrypted rclone configuration File
-
-RCLONE_ENCRYPT_V0:
-XIkAr3p+y+zai82cHFH8UoW1y1XTe6dpTzo/g4uSwqI2pfsnSSJ4JbAsRZ9nGVpx3NzROKEewlusVHNokiA4/nD4NbT+2DJrpMLg/OtLREICfuRk3tVWPKLGsmA+TLKU+IfQMO4LfrrCe2DF/lW0qA5Xu16E0Vn++jNhbwW2oB+JTkaGka8Ae3CyisM/3NUGnCOG/yb5wLH7ybUstNYPHsNFCiU1brFXQ4DNIbUFMmca+5S44vrOWvhp9QijQXlG7/JjwrkqbB/LK2gMJPTuhY2OW+4tRw1IoCXbWmwJXv5xmhPqanW92A==
\ No newline at end of file
diff --git a/docs/config.json b/docs/config.json
index 38849ee67..ea1e6888d 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -6,7 +6,7 @@
   },
   "baseurl": "https://rclone.org",
   "title": "rclone - rsync for cloud storage",
-  "description": "rclone - rsync for cloud storage: google drive, s3, swift, cloudfiles, dropbox, memstore...",
+  "description": "rclone - rsync for cloud storage: google drive, s3, gcs, azure, dropbox, box...",
   "canonifyurls": false,
   "disableKinds": [
     "taxonomy",
diff --git a/docs/content/authors.md b/docs/content/authors.md
index 8d58153c8..2eabde696 100644
--- a/docs/content/authors.md
+++ b/docs/content/authors.md
@@ -850,3 +850,4 @@ put them back in again.` >}}
   * Dave Nicolson <david.nicolson@gmail.com>
   * Katia Esposito <katia@linux.com>
   * pawsey-kbuckley <36438302+pawsey-kbuckley@users.noreply.github.com>
+  * hidewrong <167099254+hidewrong@users.noreply.github.com>
diff --git a/docs/content/docs.md b/docs/content/docs.md
index 3e3005538..c383804dd 100644
--- a/docs/content/docs.md
+++ b/docs/content/docs.md
@@ -2781,7 +2781,7 @@ it will log a high priority message if the retry was successful.
   * `6` - Less serious errors (like 461 errors from dropbox) (NoRetry errors)
   * `7` - Fatal error (one that more retries won't fix, like account suspended) (Fatal errors)
   * `8` - Transfer exceeded - limit set by --max-transfer reached
-  * `9` - Operation successful, but no files transferred
+  * `9` - Operation successful, but no files transferred (Requires [`--error-on-no-transfer`](#error-on-no-transfer))
   * `10` - Duration exceeded - limit set by --max-duration reached
 
 Environment Variables
diff --git a/docs/content/downloads.md b/docs/content/downloads.md
index 70c83261d..82179d96c 100644
--- a/docs/content/downloads.md
+++ b/docs/content/downloads.md
@@ -14,13 +14,15 @@ See the [install](https://rclone.org/install/) documentation for more details.
 
 | OS | Minimum Version | 
 |:-------:|:-------:|
-| Linux | kernel 2.6.32 |
+| Linux | Kernel 2.6.32 (or later depending on architecture, e.g. 3.1 on ARMv5) |
 | macOS (Intel) | 10.15 (Catalina) |
 | macOS (ARM64) | 11 (Big Sur) |
 | Windows | 10, Server 2016 |
 | FreeBSD | 12.2 |
 | OpenBSD | 6.9 |
 
+See [minimum requirements](https://go.dev/wiki/MinimumRequirements) and other [platform specific information](https://go.dev/wiki/#platform-specific-information) in Go Wiki for more details.
+
 ## Release {{% version %}} {#release}
 
 | Arch-OS | Windows | macOS | Linux | .deb | .rpm | FreeBSD | NetBSD | OpenBSD | Plan9 | Solaris |
diff --git a/docs/content/onedrive.md b/docs/content/onedrive.md
index 122a7b1f3..0b55629d6 100644
--- a/docs/content/onedrive.md
+++ b/docs/content/onedrive.md
@@ -754,7 +754,7 @@ Example for OneDrive Business:
 [
 	{
 		"id": "48d31887-5fad-4d73-a9f5-3c356e68a038",
-		"grantedToIdentities": [
+		"grantedToIdentitiesV2": [
 			{
 				"user": {
 					"displayName": "ryan@contoso.com"
@@ -775,7 +775,7 @@ Example for OneDrive Business:
 	},
 	{
 		"id": "5D33DD65C6932946",
-		"grantedTo": {
+		"grantedToV2": {
 			"user": {
 				"displayName": "John Doe",
 				"id": "efee1b77-fb3b-4f65-99d6-274c11914d12"
@@ -796,10 +796,12 @@ format. The [`--metadata-mapper`](https://rclone.org/docs/#metadata-mapper) tool
 be very helpful for this.
 
 When adding permissions, an email address can be provided in the `User.ID` or
-`DisplayName` properties of `grantedTo` or `grantedToIdentities`. Alternatively,
-an ObjectID can be provided in `User.ID`. At least one valid recipient must be
-provided in order to add a permission for a user. Creating a Public Link is also
-supported, if `Link.Scope` is set to `"anonymous"`.
+`DisplayName` properties of `grantedTo` or `grantedToIdentities` (these are
+deprecated on OneDrive Business -- instead, use `grantedToV2` and
+`grantedToIdentitiesV2`, respectively). Alternatively, an ObjectID can be
+provided in `User.ID`. At least one valid recipient must be provided in order to
+add a permission for a user. Creating a Public Link is also supported, if
+`Link.Scope` is set to `"anonymous"`.
 
 Example request to add a "read" permission:
 
diff --git a/docs/content/sponsor.md b/docs/content/sponsor.md
index ca1841661..89bb7c489 100644
--- a/docs/content/sponsor.md
+++ b/docs/content/sponsor.md
@@ -56,6 +56,7 @@ off donation.
 
 Thank you very much to our sponsors:
 
+{{< sponsor src="/img/logos/backblaze.svg" width="300" height="200" title="Visit our sponsor Backblaze" link="https://www.backblaze.com/cloud-storage?utm_source=publisher&utm_medium=paid&utm_campaign=break-free-app-storage-q224-rclone">}}
 {{< sponsor src="/img/logos/idrive_e2.svg" width="300" height="200" title="Visit our sponsor IDrive e2" link="https://www.idrive.com/e2/?refer=rclone">}}
 {{< sponsor src="/img/logos/warp.svg" width="300" height="200" title="Visit our sponsor warp.dev" link="https://www.warp.dev/?utm_source=rclone&utm_medium=referral&utm_campaign=rclone_20231103">}}
 {{< sponsor src="/img/logos/sia.svg" width="200" height="200" title="Visit our sponsor sia" link="https://sia.tech">}}
diff --git a/docs/content/storj.md b/docs/content/storj.md
index a377ffb87..70c369408 100644
--- a/docs/content/storj.md
+++ b/docs/content/storj.md
@@ -6,9 +6,15 @@ versionIntroduced: "v1.52"
 
 # {{< icon "fas fa-dove" >}} Storj
 
-[Storj](https://storj.io) is an encrypted, secure, and 
-cost-effective object storage service that enables you to store, back up, and 
-archive large amounts of data in a decentralized manner.
+[Storj](https://storj.io) is redefining the cloud to support the
+future of data—sustainably and economically. Storj leverages the vast
+global supply of underutilized resources to deliver better security,
+durability, and performance services. Experience up to 90% lower costs
+and carbon reduction with Storj.
+
+Storj is an encrypted, secure, and cost-effective object storage service
+that enables you to store, back up, and archive large amounts of data
+in a decentralized manner.
 
 ## Backend options
 
diff --git a/docs/layouts/chrome/menu.html b/docs/layouts/chrome/menu.html
index ed2b3dce4..a2879c6cb 100644
--- a/docs/layouts/chrome/menu.html
+++ b/docs/layouts/chrome/menu.html
@@ -10,6 +10,15 @@
 </div>
 {{end}}
 
+<div class="card">
+  <div class="card-header" style="padding: 5px 15px;">
+    Platinum Sponsor
+  </div>
+  <div class="card-body">
+    <a href="https://www.backblaze.com/cloud-storage?utm_source=publisher&utm_medium=paid&utm_campaign=break-free-app-storage-q224-rclone" target="_blank" rel="noopener" title="Visit rclone's sponsor Backblaze"><img src="/img/logos/backblaze.svg"></a><br />
+  </div>
+</div>
+
 <div class="card">
   <div class="card-header" style="padding: 5px 15px;">
     Gold Sponsor
diff --git a/fs/sync/sync.go b/fs/sync/sync.go
index 8974ca845..0d1cfc6e6 100644
--- a/fs/sync/sync.go
+++ b/fs/sync/sync.go
@@ -63,6 +63,7 @@ type syncCopyMove struct {
 	dstEmptyDirs           map[string]fs.DirEntry // potentially empty directories
 	srcEmptyDirsMu         sync.Mutex             // protect srcEmptyDirs
 	srcEmptyDirs           map[string]fs.DirEntry // potentially empty directories
+	srcMoveEmptyDirs       map[string]fs.DirEntry // potentially empty directories when moving files out of them
 	checkerWg              sync.WaitGroup         // wait for checkers
 	toBeChecked            *pipe                  // checkers channel
 	transfersWg            sync.WaitGroup         // wait for transfers
@@ -97,6 +98,7 @@ type syncCopyMove struct {
 
 // For keeping track of delayed modtime sets
 type setDirModTime struct {
+	src     fs.Directory // if set the metadata should be set too
 	dst     fs.Directory
 	dir     string
 	modTime time.Time
@@ -144,6 +146,7 @@ func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.Delete
 		dstFilesResult:         make(chan error, 1),
 		dstEmptyDirs:           make(map[string]fs.DirEntry),
 		srcEmptyDirs:           make(map[string]fs.DirEntry),
+		srcMoveEmptyDirs:       make(map[string]fs.DirEntry),
 		noTraverse:             ci.NoTraverse,
 		noCheckDest:            ci.NoCheckDest,
 		noUnicodeNormalization: ci.NoUnicodeNormalization,
@@ -155,7 +158,7 @@ func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.Delete
 		checkFirst:             ci.CheckFirst,
 		setDirMetadata:         ci.Metadata && fsrc.Features().ReadDirMetadata && fdst.Features().WriteDirMetadata,
 		setDirModTime:          (!ci.NoUpdateDirModTime && fsrc.Features().CanHaveEmptyDirectories) && (fdst.Features().WriteDirSetModTime || fdst.Features().MkdirMetadata != nil || fdst.Features().DirSetModTime != nil),
-		setDirModTimeAfter:     !ci.NoUpdateDirModTime && fsrc.Features().CanHaveEmptyDirectories && fdst.Features().DirModTimeUpdatesOnWrite,
+		setDirModTimeAfter:     !ci.NoUpdateDirModTime && (!copyEmptySrcDirs || fsrc.Features().CanHaveEmptyDirectories && fdst.Features().DirModTimeUpdatesOnWrite),
 		modifiedDirs:           make(map[string]struct{}),
 	}
 
@@ -693,49 +696,42 @@ func (s *syncCopyMove) deleteEmptyDirectories(ctx context.Context, f fs.Fs, entr
 	return nil
 }
 
-// This copies the empty directories in the slice passed in and logs
-// any errors copying the directories
-func copyEmptyDirectories(ctx context.Context, f fs.Fs, entries map[string]fs.DirEntry) error {
-	if len(entries) == 0 {
-		return nil
-	}
-
-	var okCount int
-	for _, entry := range entries {
-		dir, ok := entry.(fs.Directory)
-		if ok {
-			err := operations.Mkdir(ctx, f, dir.Remote())
-			if err != nil {
-				fs.Errorf(fs.LogDirName(f, dir.Remote()), "Failed to Mkdir: %v", err)
-			} else {
-				okCount++
-			}
-		} else {
-			fs.Errorf(f, "Not a directory: %v", entry)
+// mark the parent of entry as not empty and if entry is a directory mark it as potentially empty.
+func (s *syncCopyMove) markParentNotEmpty(entry fs.DirEntry) {
+	s.srcEmptyDirsMu.Lock()
+	defer s.srcEmptyDirsMu.Unlock()
+	// Mark entry as potentially empty if it is a directory
+	_, isDir := entry.(fs.Directory)
+	if isDir {
+		s.srcEmptyDirs[entry.Remote()] = entry
+		// if DoMove and --delete-empty-src-dirs flag is set then record the parent but
+		// don't remove any as we are about to move files out of them them making the
+		// directory empty.
+		if s.DoMove && s.deleteEmptySrcDirs {
+			s.srcMoveEmptyDirs[entry.Remote()] = entry
 		}
 	}
-
-	if accounting.Stats(ctx).Errored() {
-		fs.Debugf(f, "failed to copy %d directories", accounting.Stats(ctx).GetErrors())
-	}
-
-	if okCount > 0 {
-		fs.Debugf(f, "copied %d directories", okCount)
-	}
-	return nil
-}
-
-func (s *syncCopyMove) srcParentDirCheck(entry fs.DirEntry) {
-	// If we are moving files then we don't want to remove directories with files in them
-	// from the srcEmptyDirs as we are about to move them making the directory empty.
-	if s.DoMove {
-		return
-	}
 	parentDir := path.Dir(entry.Remote())
-	if parentDir == "." {
-		parentDir = ""
+	if isDir && s.copyEmptySrcDirs {
+		// Mark its parent as not empty
+		if parentDir == "." {
+			parentDir = ""
+		}
+		delete(s.srcEmptyDirs, parentDir)
+	}
+	if !isDir {
+		// Mark ALL its parents as not empty
+		for {
+			if parentDir == "." {
+				parentDir = ""
+			}
+			delete(s.srcEmptyDirs, parentDir)
+			if parentDir == "" {
+				break
+			}
+			parentDir = path.Dir(parentDir)
+		}
 	}
-	delete(s.srcEmptyDirs, parentDir)
 }
 
 // parseTrackRenamesStrategy turns a config string into a trackRenamesStrategy
@@ -978,10 +974,6 @@ func (s *syncCopyMove) run() error {
 	s.stopTransfers()
 	s.stopDeleters()
 
-	if s.copyEmptySrcDirs {
-		s.processError(copyEmptyDirectories(s.ctx, s.fdst, s.srcEmptyDirs))
-	}
-
 	// Delete files after
 	if s.deleteMode == fs.DeleteModeAfter {
 		if s.currentError() != nil && !s.ci.IgnoreErrors {
@@ -1008,8 +1000,8 @@ func (s *syncCopyMove) run() error {
 	// Delete empty fsrc subdirectories
 	// if DoMove and --delete-empty-src-dirs flag is set
 	if s.DoMove && s.deleteEmptySrcDirs {
-		// delete empty subdirectories that were part of the move
-		s.processError(s.deleteEmptyDirectories(s.ctx, s.fsrc, s.srcEmptyDirs))
+		// delete potentially empty subdirectories that were part of the move
+		s.processError(s.deleteEmptyDirectories(s.ctx, s.fsrc, s.srcMoveEmptyDirs))
 	}
 
 	// Read the error out of the contexts if there is one
@@ -1074,8 +1066,8 @@ func (s *syncCopyMove) DstOnly(dst fs.DirEntry) (recurse bool) {
 		if s.fdst.Features().CanHaveEmptyDirectories {
 			s.dstEmptyDirsMu.Lock()
 			s.dstEmptyDirs[dst.Remote()] = dst
-			s.logger(s.ctx, operations.MissingOnSrc, nil, dst, fs.ErrorIsDir)
 			s.dstEmptyDirsMu.Unlock()
+			s.logger(s.ctx, operations.MissingOnSrc, nil, dst, fs.ErrorIsDir)
 		}
 		return true
 	default:
@@ -1117,9 +1109,10 @@ func (s *syncCopyMove) copyDirMetadata(ctx context.Context, f fs.Fs, dst fs.Dire
 	if !s.setDirModTimeAfter && equal {
 		return nil
 	}
+	setMeta := true
 	if s.setDirModTimeAfter && equal {
 		newDst = dst
-	} else {
+	} else if s.copyEmptySrcDirs {
 		if s.setDirMetadata {
 			newDst, err = operations.CopyDirMetadata(ctx, f, dst, dir, src)
 		} else if s.setDirModTime {
@@ -1132,6 +1125,9 @@ func (s *syncCopyMove) copyDirMetadata(ctx context.Context, f fs.Fs, dst fs.Dire
 			// Create the directory if it doesn't exist
 			err = operations.Mkdir(ctx, f, dir)
 		}
+	} else {
+		setMeta = s.setDirMetadata
+		newDst = dst
 	}
 	// If we need to set modtime after and we created a dir, then save it for later
 	if s.setDirModTime && s.setDirModTimeAfter && err == nil {
@@ -1148,12 +1144,16 @@ func (s *syncCopyMove) copyDirMetadata(ctx context.Context, f fs.Fs, dst fs.Dire
 		if level > s.setDirModTimesMaxLevel {
 			s.setDirModTimesMaxLevel = level
 		}
-		s.setDirModTimes = append(s.setDirModTimes, setDirModTime{
+		set := setDirModTime{
 			dst:     newDst,
 			dir:     dir,
 			modTime: src.ModTime(ctx),
 			level:   level,
-		})
+		}
+		if setMeta {
+			set.src = src
+		}
+		s.setDirModTimes = append(s.setDirModTimes, set)
 		s.setDirModTimeMu.Unlock()
 		fs.Debugf(nil, "Added delayed dir = %q, newDst=%v", dir, newDst)
 	}
@@ -1184,15 +1184,33 @@ func (s *syncCopyMove) setDelayedDirModTimes(ctx context.Context) error {
 			if gCtx.Err() != nil {
 				break
 			}
-			item := item
 			if _, ok := s.modifiedDirs[item.dir]; !ok {
 				continue
 			}
+			if !s.copyEmptySrcDirs {
+				if _, isEmpty := s.srcEmptyDirs[item.dir]; isEmpty {
+					continue
+				}
+			}
+			item := item
+			if s.setDirModTimeAfter { // mark dir's parent as modified
+				dir := path.Dir(item.dir)
+				if dir == "." {
+					dir = ""
+				}
+				s.modifiedDirs[dir] = struct{}{} // lock is already held
+			}
 			g.Go(func() error {
-				_, err := operations.SetDirModTime(gCtx, s.fdst, item.dst, item.dir, item.modTime)
+				var err error
+				// if item.src is set must copy full metadata
+				if item.src != nil {
+					_, err = operations.CopyDirMetadata(gCtx, s.fdst, item.dst, item.dir, item.src)
+				} else {
+					_, err = operations.SetDirModTime(gCtx, s.fdst, item.dst, item.dir, item.modTime)
+				}
 				if err != nil {
 					err = fs.CountError(err)
-					fs.Errorf(item.dir, "Failed to timestamp directory: %v", err)
+					fs.Errorf(item.dir, "Failed to update directory timestamp or metadata: %v", err)
 					errCount.Add(err)
 				}
 				return nil // don't return errors, just count them
@@ -1214,12 +1232,7 @@ func (s *syncCopyMove) SrcOnly(src fs.DirEntry) (recurse bool) {
 	switch x := src.(type) {
 	case fs.Object:
 		s.logger(s.ctx, operations.MissingOnDst, x, nil, nil)
-		// If it's a copy operation,
-		// remove parent directory from srcEmptyDirs
-		// since it's not really empty
-		s.srcEmptyDirsMu.Lock()
-		s.srcParentDirCheck(src)
-		s.srcEmptyDirsMu.Unlock()
+		s.markParentNotEmpty(src)
 
 		if s.trackRenames {
 			// Save object to check for a rename later
@@ -1247,16 +1260,12 @@ func (s *syncCopyMove) SrcOnly(src fs.DirEntry) (recurse bool) {
 		}
 	case fs.Directory:
 		// Do the same thing to the entire contents of the directory
-		// Record the directory for deletion
-		s.srcEmptyDirsMu.Lock()
-		s.srcParentDirCheck(src)
-		s.srcEmptyDirs[src.Remote()] = src
+		s.markParentNotEmpty(src)
 		s.logger(s.ctx, operations.MissingOnDst, src, nil, fs.ErrorIsDir)
-		s.srcEmptyDirsMu.Unlock()
 
 		// Create the directory and make sure the Metadata/ModTime is correct
-		s.markDirModified(x.Remote())
 		s.copyDirMetadata(s.ctx, s.fdst, nil, x.Remote(), x)
+		s.markDirModified(x.Remote())
 		return true
 	default:
 		panic("Bad object in DirEntries")
@@ -1268,9 +1277,7 @@ func (s *syncCopyMove) SrcOnly(src fs.DirEntry) (recurse bool) {
 func (s *syncCopyMove) Match(ctx context.Context, dst, src fs.DirEntry) (recurse bool) {
 	switch srcX := src.(type) {
 	case fs.Object:
-		s.srcEmptyDirsMu.Lock()
-		s.srcParentDirCheck(src)
-		s.srcEmptyDirsMu.Unlock()
+		s.markParentNotEmpty(src)
 
 		if s.deleteMode == fs.DeleteModeOnly {
 			return false
@@ -1291,20 +1298,13 @@ func (s *syncCopyMove) Match(ctx context.Context, dst, src fs.DirEntry) (recurse
 		}
 	case fs.Directory:
 		// Do the same thing to the entire contents of the directory
+		s.markParentNotEmpty(src)
 		dstX, ok := dst.(fs.Directory)
 		if ok {
 			s.logger(s.ctx, operations.Match, src, dst, fs.ErrorIsDir)
 			// Create the directory and make sure the Metadata/ModTime is correct
 			s.copyDirMetadata(s.ctx, s.fdst, dstX, "", srcX)
 
-			// Only record matched (src & dst) empty dirs when performing move
-			if s.DoMove {
-				// Record the src directory for deletion
-				s.srcEmptyDirsMu.Lock()
-				s.srcParentDirCheck(src)
-				s.srcEmptyDirs[src.Remote()] = src
-				s.srcEmptyDirsMu.Unlock()
-			}
 			if s.ci.FixCase && !s.ci.Immutable && src.Remote() != dst.Remote() {
 				// Fix case for case insensitive filesystems
 				// Fix each dir before recursing into subdirs and files
diff --git a/lib/http/serve/dir.go b/lib/http/serve/dir.go
index 1cfcc454b..664f81cf2 100644
--- a/lib/http/serve/dir.go
+++ b/lib/http/serve/dir.go
@@ -235,6 +235,7 @@ func (d *Directory) Serve(w http.ResponseWriter, r *http.Request) {
 		Error(d.DirRemote, w, "Failed to render template", err)
 		return
 	}
+	w.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len()))
 	_, err = buf.WriteTo(w)
 	if err != nil {
 		Error(d.DirRemote, nil, "Failed to drain template buffer", err)
diff --git a/lib/http/server.go b/lib/http/server.go
index fff3a2b0e..3e782d2d4 100644
--- a/lib/http/server.go
+++ b/lib/http/server.go
@@ -70,7 +70,7 @@ of that with the CA certificate.  ` + "`--k{{ .Prefix }}ey`" + ` should be the P
 private key and ` + "`--{{ .Prefix }}client-ca`" + ` should be the PEM encoded client
 certificate authority certificate.
 
---{{ .Prefix }}min-tls-version is minimum TLS version that is acceptable. Valid
+` + "`--{{ .Prefix }}min-tls-version`" + ` is minimum TLS version that is acceptable. Valid
   values are "tls1.0", "tls1.1", "tls1.2" and "tls1.3" (default
   "tls1.0").
 
diff --git a/lib/oauthutil/oauthutil.go b/lib/oauthutil/oauthutil.go
index 7b0fb9d9d..2f3d5278f 100644
--- a/lib/oauthutil/oauthutil.go
+++ b/lib/oauthutil/oauthutil.go
@@ -376,6 +376,9 @@ func overrideCredentials(name string, m configmap.Mapper, origConfig *oauth2.Con
 	ClientID, ok := m.Get(config.ConfigClientID)
 	if ok && ClientID != "" {
 		newConfig.ClientID = ClientID
+		// Clear out any existing client secret since the ID changed.
+		// (otherwise it's impossible for a config to clear the secret)
+		newConfig.ClientSecret = ""
 		changed = true
 	}
 	ClientSecret, ok := m.Get(config.ConfigClientSecret)


Test patch:
diff --git a/backend/onedrive/onedrive_internal_test.go b/backend/onedrive/onedrive_internal_test.go
index f8e54940f..8940f538a 100644
--- a/backend/onedrive/onedrive_internal_test.go
+++ b/backend/onedrive/onedrive_internal_test.go
@@ -50,7 +50,7 @@ func (f *Fs) TestWritePermissions(t *testing.T, r *fstest.Run) {
 	file1 := r.WriteFile(randomFilename(), content, t2)
 
 	// add a permission with "read" role
-	permissions := defaultPermissions()
+	permissions := defaultPermissions(f.driveType)
 	permissions[0].Roles[0] = api.ReadRole
 	expectedMeta, actualMeta := f.putWithMeta(ctx, t, &file1, permissions)
 	f.compareMeta(t, expectedMeta, actualMeta, false)
@@ -59,7 +59,7 @@ func (f *Fs) TestWritePermissions(t *testing.T, r *fstest.Run) {
 	found, num := false, 0
 	foundCount := 0
 	for i, p := range actualP {
-		for _, identity := range p.GrantedToIdentities {
+		for _, identity := range p.GetGrantedToIdentities(f.driveType) {
 			if identity.User.DisplayName == testUserID {
 				// note: expected will always be element 0 here, but actual may be variable based on org settings
 				assert.Equal(t, expectedP[0].Roles, p.Roles)
@@ -68,7 +68,7 @@ func (f *Fs) TestWritePermissions(t *testing.T, r *fstest.Run) {
 			}
 		}
 		if f.driveType == driveTypePersonal {
-			if p.GrantedTo != nil && p.GrantedTo.User != (api.Identity{}) && p.GrantedTo.User.ID == testUserID { // shows up in a different place on biz vs. personal
+			if p.GetGrantedTo(f.driveType) != nil && p.GetGrantedTo(f.driveType).User != (api.Identity{}) && p.GetGrantedTo(f.driveType).User.ID == testUserID { // shows up in a different place on biz vs. personal
 				assert.Equal(t, expectedP[0].Roles, p.Roles)
 				found, num = true, i
 				foundCount++
@@ -106,7 +106,7 @@ func (f *Fs) TestWritePermissions(t *testing.T, r *fstest.Run) {
 	found = false
 	var foundP *api.PermissionsType
 	for _, p := range actualP {
-		if p.GrantedTo == nil || p.GrantedTo.User == (api.Identity{}) || p.GrantedTo.User.ID != testUserID {
+		if p.GetGrantedTo(f.driveType) == nil || p.GetGrantedTo(f.driveType).User == (api.Identity{}) || p.GetGrantedTo(f.driveType).User.ID != testUserID {
 			continue
 		}
 		found = true
@@ -134,7 +134,7 @@ func (f *Fs) TestReadPermissions(t *testing.T, r *fstest.Run) {
 	// test that what we got before vs. after is the same
 	_ = f.opt.MetadataPermissions.Set("read")
 	_, expectedMeta := f.putWithMeta(ctx, t, &file1, []*api.PermissionsType{}) // return var intentionally switched here
-	permissions := defaultPermissions()
+	permissions := defaultPermissions(f.driveType)
 	_, actualMeta := f.putWithMeta(ctx, t, &file1, permissions)
 	if f.driveType == driveTypePersonal {
 		perms, ok := actualMeta["permissions"]
@@ -150,7 +150,7 @@ func (f *Fs) TestReadMetadata(t *testing.T, r *fstest.Run) {
 	ctx, ci := fs.AddConfig(ctx)
 	ci.Metadata = true
 	file1 := r.WriteFile(randomFilename(), "hello", t2)
-	permissions := defaultPermissions()
+	permissions := defaultPermissions(f.driveType)
 
 	_ = f.opt.MetadataPermissions.Set("read,write")
 	_, actualMeta := f.putWithMeta(ctx, t, &file1, permissions)
@@ -174,7 +174,7 @@ func (f *Fs) TestDirectoryMetadata(t *testing.T, r *fstest.Run) {
 	ctx, ci := fs.AddConfig(ctx)
 	ci.Metadata = true
 	_ = f.opt.MetadataPermissions.Set("read,write")
-	permissions := defaultPermissions()
+	permissions := defaultPermissions(f.driveType)
 	permissions[0].Roles[0] = api.ReadRole
 
 	expectedMeta := fs.Metadata{
@@ -288,7 +288,7 @@ func (f *Fs) TestServerSideCopyMove(t *testing.T, r *fstest.Run) {
 	file1 := r.WriteFile(randomFilename(), content, t2)
 
 	// add a permission with "read" role
-	permissions := defaultPermissions()
+	permissions := defaultPermissions(f.driveType)
 	permissions[0].Roles[0] = api.ReadRole
 	expectedMeta, actualMeta := f.putWithMeta(ctx, t, &file1, permissions)
 	f.compareMeta(t, expectedMeta, actualMeta, false)
@@ -331,7 +331,10 @@ func (f *Fs) TestMetadataMapper(t *testing.T, r *fstest.Run) {
 	_ = f.opt.MetadataPermissions.Set("read,write")
 	file1 := r.WriteFile(randomFilename(), content, t2)
 
-	const blob = `{"Metadata":{"permissions":"[{\"grantedToIdentities\":[{\"user\":{\"id\":\"ryan@contoso.com\"}}],\"roles\":[\"read\"]}]"}}`
+	blob := `{"Metadata":{"permissions":"[{\"grantedToIdentities\":[{\"user\":{\"id\":\"ryan@contoso.com\"}}],\"roles\":[\"read\"]}]"}}`
+	if f.driveType != driveTypePersonal {
+		blob = `{"Metadata":{"permissions":"[{\"grantedToIdentitiesV2\":[{\"user\":{\"id\":\"ryan@contoso.com\"}}],\"roles\":[\"read\"]}]"}}`
+	}
 
 	// Copy
 	ci.MetadataMapper = []string{"echo", blob}
@@ -347,7 +350,7 @@ func (f *Fs) TestMetadataMapper(t *testing.T, r *fstest.Run) {
 	found := false
 	foundCount := 0
 	for _, p := range actualP {
-		for _, identity := range p.GrantedToIdentities {
+		for _, identity := range p.GetGrantedToIdentities(f.driveType) {
 			if identity.User.DisplayName == testUserID {
 				assert.Equal(t, []api.Role{api.ReadRole}, p.Roles)
 				found = true
@@ -355,7 +358,7 @@ func (f *Fs) TestMetadataMapper(t *testing.T, r *fstest.Run) {
 			}
 		}
 		if f.driveType == driveTypePersonal {
-			if p.GrantedTo != nil && p.GrantedTo.User != (api.Identity{}) && p.GrantedTo.User.ID == testUserID { // shows up in a different place on biz vs. personal
+			if p.GetGrantedTo(f.driveType) != nil && p.GetGrantedTo(f.driveType).User != (api.Identity{}) && p.GetGrantedTo(f.driveType).User.ID == testUserID { // shows up in a different place on biz vs. personal
 				assert.Equal(t, []api.Role{api.ReadRole}, p.Roles)
 				found = true
 				foundCount++
@@ -449,11 +452,18 @@ func indent(t *testing.T, s string) string {
 	return marshalPerms(t, p)
 }
 
-func defaultPermissions() []*api.PermissionsType {
+func defaultPermissions(driveType string) []*api.PermissionsType {
+	if driveType == driveTypePersonal {
+		return []*api.PermissionsType{{
+			GrantedTo:           &api.IdentitySet{User: api.Identity{}},
+			GrantedToIdentities: []*api.IdentitySet{{User: api.Identity{ID: testUserID}}},
+			Roles:               []api.Role{api.WriteRole},
+		}}
+	}
 	return []*api.PermissionsType{{
-		GrantedTo:           &api.IdentitySet{User: api.Identity{}},
-		GrantedToIdentities: []*api.IdentitySet{{User: api.Identity{ID: testUserID}}},
-		Roles:               []api.Role{api.WriteRole},
+		GrantedToV2:           &api.IdentitySet{User: api.Identity{}},
+		GrantedToIdentitiesV2: []*api.IdentitySet{{User: api.Identity{ID: testUserID}}},
+		Roles:                 []api.Role{api.WriteRole},
 	}}
 }
 
diff --git a/cmd/bisync/bisync_test.go b/cmd/bisync/bisync_test.go
index 5824a5557..d34025764 100644
--- a/cmd/bisync/bisync_test.go
+++ b/cmd/bisync/bisync_test.go
@@ -108,6 +108,8 @@ var logReplacements = []string{
 	`^(INFO  : .*?: (Made directory with|Set directory) (metadata|modification time)).*$`, dropMe,
 	// ignore sizes in directory time updates
 	`^(NOTICE: .*?: Skipped set directory modification time as --dry-run is set).*$`, dropMe,
+	// ignore sizes in directory metadata updates
+	`^(NOTICE: .*?: Skipped update directory metadata as --dry-run is set).*$`, dropMe,
 }
 
 // Some dry-run messages differ depending on the particular remote.
diff --git a/fs/sync/sync_test.go b/fs/sync/sync_test.go
index bf74a33bd..5516e5d91 100644
--- a/fs/sync/sync_test.go
+++ b/fs/sync/sync_test.go
@@ -85,7 +85,7 @@ func TestCopy(t *testing.T) {
 	r.CheckDirectoryModTimes(t, "sub dir")
 }
 
-func TestCopyMetadata(t *testing.T) {
+func testCopyMetadata(t *testing.T, createEmptySrcDirs bool) {
 	ctx := context.Background()
 	ctx, ci := fs.AddConfig(ctx)
 	ci.Metadata = true
@@ -99,6 +99,7 @@ func TestCopyMetadata(t *testing.T) {
 
 	const content = "hello metadata world!"
 	const dirPath = "metadata sub dir"
+	const emptyDirPath = "empty metadata sub dir"
 	const filePath = dirPath + "/hello metadata world"
 
 	fileMetadata := fs.Metadata{
@@ -119,6 +120,10 @@ func TestCopyMetadata(t *testing.T) {
 	_, err := operations.MkdirMetadata(ctx, r.Flocal, dirPath, dirMetadata)
 	require.NoError(t, err)
 
+	// Make the empty directory with metadata - may fall back to Mkdir
+	_, err = operations.MkdirMetadata(ctx, r.Flocal, emptyDirPath, dirMetadata)
+	require.NoError(t, err)
+
 	// Upload the file with metadata
 	in := io.NopCloser(bytes.NewBufferString(content))
 	_, err = operations.Rcat(ctx, r.Flocal, filePath, in, t1, fileMetadata)
@@ -132,7 +137,7 @@ func TestCopyMetadata(t *testing.T) {
 	}
 
 	ctx = predictDstFromLogger(ctx)
-	err = CopyDir(ctx, r.Fremote, r.Flocal, false)
+	err = CopyDir(ctx, r.Fremote, r.Flocal, createEmptySrcDirs)
 	require.NoError(t, err)
 	testLoggerVsLsf(ctx, r.Fremote, operations.GetLoggerOpt(ctx).JSON, t)
 
@@ -149,6 +154,26 @@ func TestCopyMetadata(t *testing.T) {
 	if features.ReadDirMetadata {
 		fstest.CheckEntryMetadata(ctx, t, r.Fremote, fstest.NewDirectory(ctx, t, r.Fremote, dirPath), dirMetadata)
 	}
+	if !createEmptySrcDirs {
+		// dir must not exist
+		_, err := fstest.NewDirectoryRetries(ctx, t, r.Fremote, emptyDirPath, 1)
+		assert.Error(t, err, "Not expecting to find empty directory")
+		assert.True(t, errors.Is(err, fs.ErrorDirNotFound), fmt.Sprintf("expecting wrapped %#v not: %#v", fs.ErrorDirNotFound, err))
+	} else {
+		// dir must exist
+		dir := fstest.NewDirectory(ctx, t, r.Fremote, emptyDirPath)
+		if features.ReadDirMetadata {
+			fstest.CheckEntryMetadata(ctx, t, r.Fremote, dir, dirMetadata)
+		}
+	}
+}
+
+func TestCopyMetadata(t *testing.T) {
+	testCopyMetadata(t, true)
+}
+
+func TestCopyMetadataNoEmptyDirs(t *testing.T) {
+	testCopyMetadata(t, false)
 }
 
 func TestCopyMissingDirectory(t *testing.T) {
@@ -280,7 +305,9 @@ func TestCopyEmptyDirectories(t *testing.T) {
 	ctx := context.Background()
 	r := fstest.NewRun(t)
 	file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
-	_, err := operations.MkdirModTime(ctx, r.Flocal, "sub dir2", t2)
+	_, err := operations.MkdirModTime(ctx, r.Flocal, "sub dir2/sub sub dir2", t2)
+	require.NoError(t, err)
+	_, err = operations.SetDirModTime(ctx, r.Flocal, nil, "sub dir2", t2)
 	require.NoError(t, err)
 	r.Mkdir(ctx, r.Fremote)
 
@@ -302,11 +329,37 @@ func TestCopyEmptyDirectories(t *testing.T) {
 		[]string{
 			"sub dir",
 			"sub dir2",
+			"sub dir2/sub sub dir2",
 		},
 	)
 
 	// Check that the modtimes of the directories are as expected
-	r.CheckDirectoryModTimes(t, "sub dir", "sub dir2")
+	r.CheckDirectoryModTimes(t, "sub dir", "sub dir2", "sub dir2/sub sub dir2")
+}
+
+// Test copy empty directories when we are configured not to create them
+func TestCopyNoEmptyDirectories(t *testing.T) {
+	ctx := context.Background()
+	r := fstest.NewRun(t)
+	file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+	err := operations.Mkdir(ctx, r.Flocal, "sub dir2")
+	require.NoError(t, err)
+	_, err = operations.MkdirModTime(ctx, r.Flocal, "sub dir2/sub sub dir2", t2)
+	require.NoError(t, err)
+	r.Mkdir(ctx, r.Fremote)
+
+	err = CopyDir(ctx, r.Fremote, r.Flocal, false)
+	require.NoError(t, err)
+
+	r.CheckRemoteListing(
+		t,
+		[]fstest.Item{
+			file1,
+		},
+		[]string{
+			"sub dir",
+		},
+	)
 }
 
 // Test move empty directories
@@ -383,6 +436,29 @@ func TestSyncNoUpdateDirModtime(t *testing.T) {
 	fstest.AssertTimeEqualWithPrecision(t, name, wantT, gotT, r.Fremote.Precision())
 }
 
+// Test move empty directories when we are not configured to create them
+func TestMoveNoEmptyDirectories(t *testing.T) {
+	ctx := context.Background()
+	r := fstest.NewRun(t)
+	file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+	err := operations.Mkdir(ctx, r.Flocal, "sub dir2")
+	require.NoError(t, err)
+	r.Mkdir(ctx, r.Fremote)
+
+	err = MoveDir(ctx, r.Fremote, r.Flocal, false, false)
+	require.NoError(t, err)
+
+	r.CheckRemoteListing(
+		t,
+		[]fstest.Item{
+			file1,
+		},
+		[]string{
+			"sub dir",
+		},
+	)
+}
+
 // Test sync empty directories
 func TestSyncEmptyDirectories(t *testing.T) {
 	ctx := context.Background()
@@ -474,6 +550,29 @@ func TestSyncSetDelayedModTimes(t *testing.T) {
 	r.CheckDirectoryModTimes(t, dirs...)
 }
 
+// Test sync empty directories when we are not configured to create them
+func TestSyncNoEmptyDirectories(t *testing.T) {
+	ctx := context.Background()
+	r := fstest.NewRun(t)
+	file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+	err := operations.Mkdir(ctx, r.Flocal, "sub dir2")
+	require.NoError(t, err)
+	r.Mkdir(ctx, r.Fremote)
+
+	err = Sync(ctx, r.Fremote, r.Flocal, false)
+	require.NoError(t, err)
+
+	r.CheckRemoteListing(
+		t,
+		[]fstest.Item{
+			file1,
+		},
+		[]string{
+			"sub dir",
+		},
+	)
+}
+
 // Test a server-side copy if possible, or the backup path if not
 func TestServerSideCopy(t *testing.T) {
 	ctx := context.Background()
@@ -2541,7 +2640,7 @@ func TestSyncConcurrentTruncate(t *testing.T) {
 
 // Tests that nothing is transferred when src and dst already match
 // Run the same sync twice, ensure no action is taken the second time
-func TestNothingToTransfer(t *testing.T) {
+func testNothingToTransfer(t *testing.T, copyEmptySrcDirs bool) {
 	accounting.GlobalStats().ResetCounters()
 	ctx, _ := fs.AddConfig(context.Background())
 	r := fstest.NewRun(t)
@@ -2566,7 +2665,7 @@ func TestNothingToTransfer(t *testing.T) {
 	accounting.GlobalStats().ResetCounters()
 	ctx = predictDstFromLogger(ctx)
 	output := bilib.CaptureOutput(func() {
-		err = CopyDir(ctx, r.Fremote, r.Flocal, true)
+		err = CopyDir(ctx, r.Fremote, r.Flocal, copyEmptySrcDirs)
 		require.NoError(t, err)
 	})
 	require.NotNil(t, output)
@@ -2574,7 +2673,7 @@ func TestNothingToTransfer(t *testing.T) {
 	r.CheckLocalItems(t, file1, file2)
 	r.CheckRemoteItems(t, file1, file2)
 	// Check that the modtimes of the directories are as expected
-	r.CheckDirectoryModTimes(t, "sub dir")
+	r.CheckDirectoryModTimes(t, "sub dir", "sub dir2", "sub dir2/very", "sub dir2/very/very", "sub dir2/very/very/very/very/very/nested/subdir")
 
 	// check that actions were taken
 	assert.True(t, strings.Contains(string(output), "Copied"), `expected to find at least one "Copied" log: `+string(output))
@@ -2588,7 +2687,7 @@ func TestNothingToTransfer(t *testing.T) {
 	accounting.GlobalStats().ResetCounters()
 	ctx = predictDstFromLogger(ctx)
 	output = bilib.CaptureOutput(func() {
-		err = CopyDir(ctx, r.Fremote, r.Flocal, true)
+		err = CopyDir(ctx, r.Fremote, r.Flocal, copyEmptySrcDirs)
 		require.NoError(t, err)
 	})
 	require.NotNil(t, output)
@@ -2596,15 +2695,60 @@ func TestNothingToTransfer(t *testing.T) {
 	r.CheckLocalItems(t, file1, file2)
 	r.CheckRemoteItems(t, file1, file2)
 	// Check that the modtimes of the directories are as expected
-	r.CheckDirectoryModTimes(t, "sub dir")
+	r.CheckDirectoryModTimes(t, "sub dir", "sub dir2", "sub dir2/very", "sub dir2/very/very", "sub dir2/very/very/very/very/very/nested/subdir")
 
 	// check that actions were NOT taken
 	assert.False(t, strings.Contains(string(output), "Copied"), `expected to find no "Copied" logs, but found one: `+string(output))
 	if r.Fremote.Features().DirSetModTime != nil || r.Fremote.Features().MkdirMetadata != nil {
 		assert.False(t, strings.Contains(string(output), "Set directory modification time"), `expected to find no "Set directory modification time" logs, but found one: `+string(output))
+		assert.False(t, strings.Contains(string(output), "Updated directory metadata"), `expected to find no "Updated directory metadata" logs, but found one: `+string(output))
+		assert.False(t, strings.Contains(string(output), "directory"), `expected to find no "directory"-related logs, but found one: `+string(output)) // catch-all
 	}
 	assert.True(t, strings.Contains(string(output), "There was nothing to transfer"), `expected to find a "There was nothing to transfer" log: `+string(output))
 	assert.Equal(t, int64(0), accounting.GlobalStats().GetTransfers())
+
+	// check nested empty dir behavior (FIXME: probably belongs in a separate test)
+	if r.Fremote.Features().DirSetModTime == nil && r.Fremote.Features().MkdirMetadata == nil {
+		return
+	}
+	file3 := r.WriteFile("sub dir2/sub dir3/hello world", "hello again, world", t1)
+	_, err = operations.SetDirModTime(ctx, r.Flocal, nil, "sub dir2", t1)
+	assert.NoError(t, err)
+	_, err = operations.SetDirModTime(ctx, r.Fremote, nil, "sub dir2", t1)
+	assert.NoError(t, err)
+	_, err = operations.MkdirModTime(ctx, r.Flocal, "sub dirEmpty/sub dirEmpty2", t2)
+	assert.NoError(t, err)
+	_, err = operations.SetDirModTime(ctx, r.Flocal, nil, "sub dirEmpty", t2)
+	assert.NoError(t, err)
+
+	accounting.GlobalStats().ResetCounters()
+	ctx = predictDstFromLogger(ctx)
+	output = bilib.CaptureOutput(func() {
+		err = CopyDir(ctx, r.Fremote, r.Flocal, copyEmptySrcDirs)
+		require.NoError(t, err)
+	})
+	require.NotNil(t, output)
+	testLoggerVsLsf(ctx, r.Fremote, operations.GetLoggerOpt(ctx).JSON, t)
+	r.CheckLocalItems(t, file1, file2, file3)
+	r.CheckRemoteItems(t, file1, file2, file3)
+	// Check that the modtimes of the directories are as expected
+	r.CheckDirectoryModTimes(t, "sub dir", "sub dir2", "sub dir2/very", "sub dir2/very/very", "sub dir2/very/very/very/very/very/nested/subdir", "sub dir2/sub dir3")
+	if copyEmptySrcDirs {
+		r.CheckDirectoryModTimes(t, "sub dirEmpty", "sub dirEmpty/sub dirEmpty2")
+		assert.True(t, strings.Contains(string(output), "sub dirEmpty:"), `expected to find at least one "sub dirEmpty:" log: `+string(output))
+	} else {
+		assert.False(t, strings.Contains(string(output), "sub dirEmpty:"), `expected to find no "sub dirEmpty:" logs, but found one (empty dir was synced and shouldn't have been): `+string(output))
+	}
+	assert.True(t, strings.Contains(string(output), "sub dir3:"), `expected to find at least one "sub dir3:" log: `+string(output))
+	assert.False(t, strings.Contains(string(output), "sub dir2/very:"), `expected to find no "sub dir2/very:" logs, but found one (unmodified dir was marked modified): `+string(output))
+}
+
+func TestNothingToTransferWithEmptyDirs(t *testing.T) {
+	testNothingToTransfer(t, true)
+}
+
+func TestNothingToTransferWithoutEmptyDirs(t *testing.T) {
+	testNothingToTransfer(t, false)
 }
 
 // for testing logger:
diff --git a/fstest/fstest.go b/fstest/fstest.go
index 835a76d60..d29c25422 100644
--- a/fstest/fstest.go
+++ b/fstest/fstest.go
@@ -27,6 +27,7 @@ import (
 	"github.com/rclone/rclone/fs/config/configfile"
 	"github.com/rclone/rclone/fs/hash"
 	"github.com/rclone/rclone/fs/walk"
+	"github.com/rclone/rclone/fstest/testy"
 	"github.com/rclone/rclone/lib/random"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -539,10 +540,12 @@ func NewObject(ctx context.Context, t *testing.T, f fs.Fs, remote string) fs.Obj
 	return obj
 }
 
-// NewDirectory finds the directory with remote in f
+// NewDirectoryRetries finds the directory with remote in f
+//
+// If directory can't be found it returns an error wrapping fs.ErrorDirNotFound
 //
 // One day this will be an rclone primitive
-func NewDirectory(ctx context.Context, t *testing.T, f fs.Fs, remote string) fs.Directory {
+func NewDirectoryRetries(ctx context.Context, t *testing.T, f fs.Fs, remote string, retries int) (fs.Directory, error) {
 	var err error
 	var dir fs.Directory
 	sleepTime := 1 * time.Second
@@ -550,7 +553,7 @@ func NewDirectory(ctx context.Context, t *testing.T, f fs.Fs, remote string) fs.
 	if root == "." {
 		root = ""
 	}
-	for i := 1; i <= *ListRetries; i++ {
+	for i := 1; i <= retries; i++ {
 		var entries fs.DirEntries
 		entries, err = f.List(ctx, root)
 		if err != nil {
@@ -560,14 +563,24 @@ func NewDirectory(ctx context.Context, t *testing.T, f fs.Fs, remote string) fs.
 			var ok bool
 			dir, ok = entry.(fs.Directory)
 			if ok && dir.Remote() == remote {
-				return dir
+				return dir, nil
 			}
 		}
-		err = fmt.Errorf("directory %q not found in %q", remote, root)
-		t.Logf("Sleeping for %v for findDir eventual consistency: %d/%d (%v)", sleepTime, i, *ListRetries, err)
-		time.Sleep(sleepTime)
-		sleepTime = (sleepTime * 3) / 2
+		err = fmt.Errorf("directory %q not found in %q: %w", remote, root, fs.ErrorDirNotFound)
+		if i < retries {
+			t.Logf("Sleeping for %v for NewDirectoryRetries eventual consistency: %d/%d (%v)", sleepTime, i, retries, err)
+			time.Sleep(sleepTime)
+			sleepTime = (sleepTime * 3) / 2
+		}
 	}
+	return dir, err
+}
+
+// NewDirectory finds the directory with remote in f
+//
+// One day this will be an rclone primitive
+func NewDirectory(ctx context.Context, t *testing.T, f fs.Fs, remote string) fs.Directory {
+	dir, err := NewDirectoryRetries(ctx, t, f, remote, *ListRetries)
 	require.NoError(t, err)
 	return dir
 }
@@ -608,7 +621,15 @@ func CheckDirModTime(ctx context.Context, t *testing.T, f fs.Fs, dir fs.Director
 		return
 	}
 	gotT := dir.ModTime(ctx)
-	AssertTimeEqualWithPrecision(t, dir.Remote(), wantT, gotT, f.Precision())
+	precision := f.Precision()
+	// For unknown reasons the precision of modification times of
+	// directories on the CI is about >15mS. The tests work fine
+	// when run in Virtualbox though so I conjecture this is
+	// something to do with the file system used there.
+	if runtime.GOOS == "windows" && testy.CI() {
+		precision = 100 * time.Millisecond
+	}
+	AssertTimeEqualWithPrecision(t, dir.Remote(), wantT, gotT, precision)
 }
 
 // Gz returns a compressed version of its input string
diff --git a/fstest/testy/testy.go b/fstest/testy/testy.go
index ca2d4ebe8..5b73c0139 100644
--- a/fstest/testy/testy.go
+++ b/fstest/testy/testy.go
@@ -6,9 +6,14 @@ import (
 	"testing"
 )
 
+// CI returns true if we are running on the CI server
+func CI() bool {
+	return os.Getenv("CI") != ""
+}
+
 // SkipUnreliable skips this test if running on CI
 func SkipUnreliable(t *testing.T) {
-	if os.Getenv("CI") == "" {
+	if !CI() {
 		return
 	}
 	t.Skip("Skipping Unreliable Test on CI")