diff --git a/api/custom/datastores.go b/api/custom/datastores.go
index 52425fbadb6f01a8c0411660af1f17918a57e429..19119858739aa96441c109c272f719dee01146f2 100644
--- a/api/custom/datastores.go
+++ b/api/custom/datastores.go
@@ -15,12 +15,11 @@ import (
 	"github.com/turt2live/matrix-media-repo/common/rcontext"
 	"github.com/turt2live/matrix-media-repo/controllers/maintenance_controller"
 	"github.com/turt2live/matrix-media-repo/storage/datastore"
-	"github.com/turt2live/matrix-media-repo/types"
 	"github.com/turt2live/matrix-media-repo/util"
 )
 
 type DatastoreMigration struct {
-	*types.DatastoreMigrationEstimate
+	*datastores.SizeEstimate
 	TaskID int `json:"task_id"`
 }
 
@@ -78,24 +77,24 @@ func MigrateBetweenDatastores(r *http.Request, rctx rcontext.RequestContext, use
 		return _responses.BadRequest("Error getting target datastore. Does it exist?")
 	}
 
-	rctx.Log.Info("User ", user.UserId, " has started a datastore media transfer")
-	task, err := maintenance_controller.StartStorageMigration(sourceDatastore, targetDatastore, beforeTs, rctx)
+	estimate, err := datastores.SizeOfDsIdWithAge(rctx, sourceDsId, beforeTs)
 	if err != nil {
 		rctx.Log.Error(err)
 		sentry.CaptureException(err)
-		return _responses.InternalServerError("Unexpected error starting migration")
+		return _responses.InternalServerError("Unexpected error getting storage estimate")
 	}
 
-	estimate, err := maintenance_controller.EstimateDatastoreSizeWithAge(beforeTs, sourceDsId, rctx)
+	rctx.Log.Infof("User %s has started a datastore media transfer", user.UserId)
+	task, err := maintenance_controller.StartStorageMigration(sourceDatastore, targetDatastore, beforeTs, rctx)
 	if err != nil {
 		rctx.Log.Error(err)
 		sentry.CaptureException(err)
-		return _responses.InternalServerError("Unexpected error getting storage estimate")
+		return _responses.InternalServerError("Unexpected error starting migration")
 	}
 
 	migration := &DatastoreMigration{
-		DatastoreMigrationEstimate: estimate,
-		TaskID:                     task.ID,
+		SizeEstimate: estimate,
+		TaskID:       task.ID,
 	}
 
 	return &_responses.DoNotCacheResponse{Payload: migration}
@@ -119,7 +118,7 @@ func GetDatastoreStorageEstimate(r *http.Request, rctx rcontext.RequestContext,
 		"datastoreId": datastoreId,
 	})
 
-	result, err := maintenance_controller.EstimateDatastoreSizeWithAge(beforeTs, datastoreId, rctx)
+	result, err := datastores.SizeOfDsIdWithAge(rctx, datastoreId, beforeTs)
 	if err != nil {
 		rctx.Log.Error(err)
 		sentry.CaptureException(err)
diff --git a/database/virtualtable_metadata.go b/database/virtualtable_metadata.go
index 3a499da1504c28bd22cd520c76f6c68ebb5c4ca1..6ad1dcb449ea6a5c3fe9d1093b6365ad60f95c78 100644
--- a/database/virtualtable_metadata.go
+++ b/database/virtualtable_metadata.go
@@ -9,9 +9,18 @@ import (
 	"github.com/turt2live/matrix-media-repo/common/rcontext"
 )
 
+type VirtLastAccess struct {
+	*Locatable
+	SizeBytes    int64
+	CreationTs   int64
+	LastAccessTs int64
+}
+
 const selectEstimatedDatastoreSize = "SELECT COALESCE(SUM(m2.size_bytes), 0) + COALESCE((SELECT SUM(t2.size_bytes) FROM (SELECT DISTINCT t.sha256_hash, MAX(t.size_bytes) AS size_bytes FROM thumbnails AS t WHERE t.datastore_id = $1 GROUP BY t.sha256_hash) AS t2), 0) AS size_total FROM (SELECT DISTINCT m.sha256_hash, MAX(m.size_bytes) AS size_bytes FROM media AS m WHERE m.datastore_id = $1 GROUP BY m.sha256_hash) AS m2;"
 const selectUploadSizesForServer = "SELECT COALESCE((SELECT SUM(size_bytes) FROM media WHERE origin = $1), 0) AS media, COALESCE((SELECT SUM(size_bytes) FROM thumbnails WHERE origin = $1), 0) AS thumbnails;"
 const selectUploadCountsForServer = "SELECT COALESCE((SELECT COUNT(origin) FROM media WHERE origin = $1), 0) AS media, COALESCE((SELECT COUNT(origin) FROM thumbnails WHERE origin = $1), 0) AS thumbnails;"
+const selectMediaForDatastoreWithLastAccess = "SELECT m.sha256_hash, m.size_bytes, m.datastore_id, m.location, m.creation_ts, a.last_access_ts FROM media AS m JOIN last_access AS a ON m.sha256_hash = a.sha256_hash WHERE a.last_access_ts < $1 AND m.datastore_id = $2;"
+const selectThumbnailsForDatastoreWithLastAccess = "SELECT m.sha256_hash, m.size_bytes, m.datastore_id, m.location, m.creation_ts, a.last_access_ts FROM thumbnails AS m JOIN last_access AS a ON m.sha256_hash = a.sha256_hash WHERE a.last_access_ts < $1 AND m.datastore_id = $2;"
 
 type SynStatUserOrderBy string
 
@@ -36,9 +45,11 @@ type DbSynUserStat struct {
 type metadataVirtualTableStatements struct {
 	db *sql.DB
 
-	selectEstimatedDatastoreSize *sql.Stmt
-	selectUploadSizesForServer   *sql.Stmt
-	selectUploadCountsForServer  *sql.Stmt
+	selectEstimatedDatastoreSize               *sql.Stmt
+	selectUploadSizesForServer                 *sql.Stmt
+	selectUploadCountsForServer                *sql.Stmt
+	selectMediaForDatastoreWithLastAccess      *sql.Stmt
+	selectThumbnailsForDatastoreWithLastAccess *sql.Stmt
 }
 
 type metadataVirtualTableWithContext struct {
@@ -61,6 +72,12 @@ func prepareMetadataVirtualTables(db *sql.DB) (*metadataVirtualTableStatements,
 	if stmts.selectUploadCountsForServer, err = db.Prepare(selectUploadCountsForServer); err != nil {
 		return nil, errors.New("error preparing selectUploadCountsForServer: " + err.Error())
 	}
+	if stmts.selectMediaForDatastoreWithLastAccess, err = db.Prepare(selectMediaForDatastoreWithLastAccess); err != nil {
+		return nil, errors.New("error preparing selectMediaForDatastoreWithLastAccess: " + err.Error())
+	}
+	if stmts.selectThumbnailsForDatastoreWithLastAccess, err = db.Prepare(selectThumbnailsForDatastoreWithLastAccess); err != nil {
+		return nil, errors.New("error preparing selectThumbnailsForDatastoreWithLastAccess: " + err.Error())
+	}
 
 	return stmts, nil
 }
@@ -183,3 +200,30 @@ func (s *metadataVirtualTableWithContext) UnoptimizedSynapseUserStatsPage(server
 
 	return results, total, nil
 }
+
+func (s *metadataVirtualTableWithContext) scanLastAccess(rows *sql.Rows, err error) ([]*VirtLastAccess, error) {
+	results := make([]*VirtLastAccess, 0)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			return results, nil
+		}
+		return nil, err
+	}
+	for rows.Next() {
+		val := &VirtLastAccess{Locatable: &Locatable{}}
+		if err = rows.Scan(&val.Sha256Hash, &val.SizeBytes, &val.DatastoreId, &val.Location, &val.CreationTs, &val.LastAccessTs); err != nil {
+			return nil, err
+		}
+		results = append(results, val)
+	}
+
+	return results, nil
+}
+
+func (s *metadataVirtualTableWithContext) GetMediaForDatastoreByLastAccess(datastoreId string, lastAccessTs int64) ([]*VirtLastAccess, error) {
+	return s.scanLastAccess(s.statements.selectMediaForDatastoreWithLastAccess.QueryContext(s.ctx, lastAccessTs, datastoreId))
+}
+
+func (s *metadataVirtualTableWithContext) GetThumbnailsForDatastoreByLastAccess(datastoreId string, lastAccessTs int64) ([]*VirtLastAccess, error) {
+	return s.scanLastAccess(s.statements.selectThumbnailsForDatastoreWithLastAccess.QueryContext(s.ctx, lastAccessTs, datastoreId))
+}
diff --git a/datastores/info.go b/datastores/info.go
index 34549cd04f61d941ecbca84b98269c0fc43a6306..bed33a1c7de1b7c8dca30fc2613209e17b74ce16 100644
--- a/datastores/info.go
+++ b/datastores/info.go
@@ -5,8 +5,23 @@ import (
 	"fmt"
 
 	"github.com/turt2live/matrix-media-repo/common/config"
+	"github.com/turt2live/matrix-media-repo/common/rcontext"
+	"github.com/turt2live/matrix-media-repo/database"
 )
 
+type SizeEstimate struct {
+	ThumbnailsAffected      int64 `json:"thumbnails_affected"`
+	ThumbnailHashesAffected int64 `json:"thumbnail_hashes_affected"`
+	ThumbnailBytes          int64 `json:"thumbnail_bytes"`
+
+	MediaAffected       int64 `json:"media_affected"`
+	MediaHashesAffected int64 `json:"media_hashes_affected"`
+	MediaBytes          int64 `json:"media_bytes"`
+
+	TotalHashesAffected int64 `json:"total_hashes_affected"`
+	TotalBytes          int64 `json:"total_bytes"`
+}
+
 func GetUri(ds config.DatastoreConfig) (string, error) {
 	if ds.Type == "s3" {
 		s3c, err := getS3(ds)
@@ -20,3 +35,53 @@ func GetUri(ds config.DatastoreConfig) (string, error) {
 		return "", errors.New("unknown datastore type - contact developer")
 	}
 }
+
+func SizeOfDsIdWithAge(ctx rcontext.RequestContext, dsId string, beforeTs int64) (*SizeEstimate, error) {
+	db := database.GetInstance().MetadataView.Prepare(ctx)
+	media, err := db.GetMediaForDatastoreByLastAccess(dsId, beforeTs)
+	if err != nil {
+		return nil, err
+	}
+	thumbs, err := db.GetThumbnailsForDatastoreByLastAccess(dsId, beforeTs)
+	if err != nil {
+		return nil, err
+	}
+
+	estimate := &SizeEstimate{}
+	seenHashes := make(map[string]bool)
+	seenMediaHashes := make(map[string]bool)
+	seenThumbnailHashes := make(map[string]bool)
+
+	for _, record := range media {
+		estimate.MediaAffected++
+
+		if _, found := seenHashes[record.Sha256Hash]; !found {
+			estimate.TotalBytes += record.SizeBytes
+			estimate.TotalHashesAffected++
+		}
+		if _, found := seenMediaHashes[record.Sha256Hash]; !found {
+			estimate.MediaBytes += record.SizeBytes
+			estimate.MediaHashesAffected++
+		}
+
+		seenHashes[record.Sha256Hash] = true
+		seenMediaHashes[record.Sha256Hash] = true
+	}
+	for _, record := range thumbs {
+		estimate.ThumbnailsAffected++
+
+		if _, found := seenHashes[record.Sha256Hash]; !found {
+			estimate.TotalBytes += record.SizeBytes
+			estimate.TotalHashesAffected++
+		}
+		if _, found := seenThumbnailHashes[record.Sha256Hash]; !found {
+			estimate.ThumbnailBytes += record.SizeBytes
+			estimate.ThumbnailHashesAffected++
+		}
+
+		seenHashes[record.Sha256Hash] = true
+		seenThumbnailHashes[record.Sha256Hash] = true
+	}
+
+	return estimate, nil
+}