Repository: gphotosuploader/gphotos-uploader-cli (go)

Issue:
Enable makeAlbums to use more than the last component of the folder name
When a lot of photos are being shared, the last component of a file path can collide (e.g. 2018/Christmas with 2019/Christmas). 

GooglePhoto does not support hierarchical structures. Concatenating path elements would probably work for many use case (e.g. 2018_Christmas and 2019_Christmas or 2018/Christmas and 2019/Christmas). 


Pull request:
Add option to configure Album name
**What issue type does this pull request address?**
enhancement

**What is this pull request for? Which issues does it resolve?** 
This issue adds three different things:
1. Add a new `push` command to upload objects to Google Photos. Deprecating `gphotos-uploader-cli` wo/ command.
2. Add `--silent` and `--debug` flags to control output verbosity.
3. Add a configuration option to create Album names based on full folder path. Resolves #150 

**Does this pull request has user-facing changes?** 
- The CLI **must** run with `push` command: `gphotos-uploader-push`.
- New configuration options are available. See [documentation](https://github.com/gphotosuploader/gphotos-uploader-cli/blob/master/.docs/configuration.md)

**What else do we need to know?**  
- Add a breaking change. Now you **must** run the CLI with a command.

Patch:
diff --git a/.docs/configuration.md b/.docs/configuration.md
index 8f28ad8..298c1fc 100644
--- a/.docs/configuration.md
+++ b/.docs/configuration.md
@@ -17,7 +17,7 @@ Example configuration file:
       sourceFolder: ~/folder/to/upload
       makeAlbums: {
         enabled: true
-        use: folderNames
+        use: folderName
       }
       deleteAfterUpload: false
       uploadVideos: true
@@ -86,7 +86,22 @@ The folder to upload from.
 Must be an absolute path. Can expand the home folder tilde shorthand.
 
 ### `makeAlbums`
-If makeAlbums.enabled set to true, use the last folder path component as album name.
+If `makeAlbums.enabled` set to true, use the last folder path component as album name. You can customize the name of the created albums with `makeAlbums.use`.
+
+Available options are:
+
+* `folderName`: It will use the name of the item's containing folder as Album name.
+* `folderPath`: It will use the full path of the  item's containing folder as Album name.
+
+```
+# for file: /foo/bar/xyz/file.jpg
+
+use: folderName
+# album name: xyz
+
+use: folderPath
+# album name: foo_bar_xyz
+```
 
 ### `deleteAfterUpload`
 If set to true, media will be deleted from local disk after upload. 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0250fb4..367d5db 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,11 +7,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/) and this
 ### Added
 - [CONTRIBUTING](CONTRIBUTING.md) guide line has been added.
 - New Logger package to improve log readability.
+- New flags to control CLI verbosity: `--silent` suppress all logs except Fatal ones, `--debug` enable a lot of verbosity to logs.
+- New option for Album creation: `use: folderPath` will use the full folder path as Album name. See [config documentation](.docs/configuration.md#makeAlbums). ([#150][i150])
 ### Changed
+- **ATTENTION**: To upload items, you **must** run `gphotos-uploader-cli push`. The new `push` command substitutes `gphotos-uploader-cli`, that was working in previous versions.
 - [README](README.md) has been updated fixing some typos.
+- Default log verbosity is now `INFO`, use `--debug` if you want more verbose output.
 ### Deprecated
 - Once Go 1.13 has been published, previous Go 1.11 support is deprecated. This project will maintain compatibility with the last two major versions published.
 
+[i150]: https://github.com/gphotosuploader/gphotos-uploader-cli/issues/150
+
 ## 0.8.7
 ### Changed
 - Update dependencies to newer versions: [gphotosuploader/google-photos-api-client-go](https://github.com/gphotosuploader/google-photos-api-client-go) v1.1.2 and [int128/oauth2cli](https://github.com/int128/oauth2cli) to v1.7.0.
@@ -22,7 +28,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/) and this
 ### Changed
 - Remove `build` from version. Now `version` has all the tag+build information.
 ### Fixed
-- Fix duplicated album creation. (#135)
+- Fix duplicated album creation. ([#135][i135])
+
+[i135]: https://github.com/gphotosuploader/gphotos-uploader-cli/issues/135
 
 ## 0.8.5
 ### Fixed
diff --git a/app/app.go b/app/app.go
index fd13bd1..840240f 100644
--- a/app/app.go
+++ b/app/app.go
@@ -11,7 +11,7 @@ type App struct {
 	TokenManager  TokenManager
 	UploadTracker UploadTracker
 
-	Log log.Logger
+	Logger log.Logger
 }
 
 type FileTracker interface {
diff --git a/app/oauth.go b/app/oauth.go
index 6f6bc88..554c3d0 100644
--- a/app/oauth.go
+++ b/app/oauth.go
@@ -27,7 +27,7 @@ func newHTTPClient() *http.Client {
 func (app *App) NewOAuth2Client(ctx context.Context, oauth2Config oauth2.Config, account string) (*http.Client, error) {
 	token, err := app.TokenManager.RetrieveToken(account)
 	if err != nil {
-		app.Log.Debugf("Token has not been retrieved from token store: %s", err)
+		app.Logger.Debugf("Token has not been retrieved from token store: %s", err)
 	}
 
 	ctx = context.WithValue(ctx, oauth2.HTTPClient, newHTTPClient())
@@ -39,7 +39,7 @@ func (app *App) NewOAuth2Client(ctx context.Context, oauth2Config oauth2.Config,
 		}
 
 	case !token.Valid():
-		app.Log.Info("Token has been expired, refreshing")
+		app.Logger.Info("Token has been expired, refreshing")
 		token, err = oauth2Config.TokenSource(ctx, token).Token()
 		if err != nil {
 			return nil, fmt.Errorf("could not refresh the token: %s", err)
@@ -48,7 +48,7 @@ func (app *App) NewOAuth2Client(ctx context.Context, oauth2Config oauth2.Config,
 
 	// debug
 	if token != nil {
-		app.Log.Debugf("Token expiration: %s", token.Expiry.String())
+		app.Logger.Debugf("Token expiration: %s", token.Expiry.String())
 	}
 
 	// and store the token into the keyring
@@ -74,10 +74,10 @@ func (app *App) obtainOAuthTokenFromAuthServer(ctx context.Context, oauth2Config
 				return nil
 			}
 			// Open a browser to complete OAuth process.
-			app.Log.Info("Opening browser to complete authorization.")
+			app.Logger.Info("Opening browser to complete authorization.")
 			err = browser.OpenURL(url)
 			if err != nil {
-				app.Log.Warnf("Browser was not detected. Complete the authorization browsing to: %s", url)
+				app.Logger.Warnf("Browser was not detected. Complete the authorization browsing to: %s", url)
 			}
 			return nil
 		case err := <-ctx.Done():
@@ -94,7 +94,7 @@ func (app *App) obtainOAuthTokenFromAuthServer(ctx context.Context, oauth2Config
 		return err
 	})
 	if err := eg.Wait(); err != nil {
-		app.Log.Errorf("error while authorization: %s", err)
+		app.Logger.Errorf("error while authorization: %s", err)
 	}
 
 	return token, err
diff --git a/cmd/push.go b/cmd/push.go
index af67b99..0e41112 100644
--- a/cmd/push.go
+++ b/cmd/push.go
@@ -40,91 +40,104 @@ func NewPushCmd(globalFlags *flags.GlobalFlags) *cobra.Command {
 }
 
 func (cmd *PushCmd) Run(cobraCmd *cobra.Command, args []string) error {
-		cfg, err := config.LoadConfig(cmd.CfgDir)
-		if err != nil {
-			return fmt.Errorf("could't read configuration. Please review your configuration or run 'gphotos-uploader-cli init': file=%s, err=%s", cmd.CfgDir, err)
-		}
-		err = cfg.Validate()
-		if err != nil {
-			return fmt.Errorf("invalid configuration: file=%s, err=%s", cfg.ConfigFile(), err)
-		}
+	cfg, err := config.LoadConfig(cmd.CfgDir)
+	if err != nil {
+		return fmt.Errorf("could't read configuration. Please review your configuration or run 'gphotos-uploader-cli init': file=%s, err=%s", cmd.CfgDir, err)
+	}
+	err = cfg.Validate()
+	if err != nil {
+		return fmt.Errorf("invalid configuration: file=%s, err=%s", cfg.ConfigFile(), err)
+	}
 
-		// File Tracker service to track uploaded files.
-		ft, err := leveldb.OpenFile(cfg.CompletedUploadsDBDir(), nil)
-		if err != nil {
-			return fmt.Errorf("open completed uploads tracker failed: path=%s, err=%s", cfg.CompletedUploadsDBDir(), err)
-		}
-		defer ft.Close()
-		fileTracker := completeduploads.NewService(completeduploads.NewLevelDBRepository(ft))
+	// File Tracker service to track uploaded files.
+	ft, err := leveldb.OpenFile(cfg.CompletedUploadsDBDir(), nil)
+	if err != nil {
+		return fmt.Errorf("open completed uploads tracker failed: path=%s, err=%s", cfg.CompletedUploadsDBDir(), err)
+	}
+	defer ft.Close()
+	fileTracker := completeduploads.NewService(completeduploads.NewLevelDBRepository(ft))
 
-		// Token Manager service to be used as secrets backend.
-		kr, err := tokenstore.NewKeyringRepository(cfg.SecretsBackendType, nil, cfg.KeyringDir())
-		if err != nil {
-			return fmt.Errorf("open token manager failed: type=%s, err=%s", cfg.SecretsBackendType, err)
-		}
-		tokenManager := tokenstore.NewService(kr)
+	// Token Manager service to be used as secrets backend.
+	kr, err := tokenstore.NewKeyringRepository(cfg.SecretsBackendType, nil, cfg.KeyringDir())
+	if err != nil {
+		return fmt.Errorf("open token manager failed: type=%s, err=%s", cfg.SecretsBackendType, err)
+	}
+	tokenManager := tokenstore.NewService(kr)
 
-		// Upload Session Tracker to keep upload session to resume uploads.
-		uploadTracker, err := leveldbstore.NewStore(cfg.ResumableUploadsDBDir())
-		if err != nil {
-			return fmt.Errorf("open resumable uploads tracker failed: path=%s, err=%s", cfg.ResumableUploadsDBDir(), err)
-		}
-		defer uploadTracker.Close()
+	// Upload Session Tracker to keep upload session to resume uploads.
+	uploadTracker, err := leveldbstore.NewStore(cfg.ResumableUploadsDBDir())
+	if err != nil {
+		return fmt.Errorf("open resumable uploads tracker failed: path=%s, err=%s", cfg.ResumableUploadsDBDir(), err)
+	}
+	defer uploadTracker.Close()
 
-		// Initialize the App
-		app := &app.App{
-			FileTracker:   fileTracker,
-			TokenManager:  tokenManager,
-			UploadTracker: uploadTracker,
+	// Initialize the App
+	app := &app.App{
+		FileTracker:   fileTracker,
+		TokenManager:  tokenManager,
+		UploadTracker: uploadTracker,
+
+		Logger: log.GetInstance(),
+	}
 
-			Log: log.GetInstance(),
+	// start file upload worker
+	uploadChan, doneUploading := upload.StartFileUploadWorker(app.FileTracker, app.Logger)
+
+	deletionQueue := upload.NewDeletionQueue()
+	deletionQueue.StartWorkers(app.Logger)
+
+	ctx := context.Background()
+	// get OAuth2 Configuration with our App credentials
+	oauth2Config := oauth2.Config{
+		ClientID:     cfg.APIAppCredentials.ClientID,
+		ClientSecret: cfg.APIAppCredentials.ClientSecret,
+		Endpoint:     photos.Endpoint,
+		Scopes:       photos.Scopes,
+	}
+
+	// launch all folder upload jobs
+	for _, item := range cfg.Jobs {
+		c, err := app.NewOAuth2Client(ctx, oauth2Config, item.Account)
+		if err != nil {
+			return err
 		}
 
-		// start file upload worker
-		uploadChan, doneUploading := upload.StartFileUploadWorker(app.FileTracker, app.Log)
+		gPhotos, err := gphotos.NewClientWithResumableUploads(c, app.UploadTracker)
+		if err != nil {
+			return err
+		}
 
-		deletionQueue := upload.NewDeletionQueue()
-		deletionQueue.StartWorkers(app.Log)
+		opt := upload.NewJobOptions(
+			item.MakeAlbums.Enabled,
+			item.MakeAlbums.Use,
+			item.DeleteAfterUpload,
+			upload.NewFilter(item.IncludePatterns, item.ExcludePatterns, item.UploadVideos),
+		)
+		job := upload.NewFolderUploadJob(gPhotos, app.FileTracker, item.SourceFolder, opt)
 
-		ctx := context.Background()
-		// get OAuth2 Configuration with our App credentials
-		oauth2Config := oauth2.Config{
-			ClientID:     cfg.APIAppCredentials.ClientID,
-			ClientSecret: cfg.APIAppCredentials.ClientSecret,
-			Endpoint:     photos.Endpoint,
-			Scopes:       photos.Scopes,
+		// get items to be uploaded
+		jobs, err := job.ScanFolder(app.Logger)
+		if err != nil {
+			log.Fatalf("Failed to upload folder %s: %v", item.SourceFolder, err)
 		}
 
-		// launch all folder upload jobs
-		for _, item := range cfg.Jobs {
-			c, err := app.NewOAuth2Client(ctx, oauth2Config, item.Account)
-			if err != nil {
-				return err
-			}
-			gPhotos, err := gphotos.NewClientWithResumableUploads(c, app.UploadTracker)
-			if err != nil {
-				return err
-			}
-
-			opt := upload.NewJobOptions(item.MakeAlbums.Enabled, item.DeleteAfterUpload, item.UploadVideos, item.IncludePatterns, item.ExcludePatterns)
-			job := upload.NewFolderUploadJob(gPhotos, app.FileTracker, item.SourceFolder, opt)
-
-			if err := job.ScanFolder(uploadChan, app.Log); err != nil {
-				log.Fatalf("Failed to upload folder %s: %v", item.SourceFolder, err)
-			}
+		// enqueue items to be uploaded
+		for _, j := range jobs {
+			uploadChan <- &j
 		}
+	}
 
-		// after we've run all the folder upload jobs we're done adding file upload jobs
-		close(uploadChan)
-		// wait for all the uploads to be completed
-		<-doneUploading
-		log.Done("all uploads done")
+	// after we've run all the folder upload jobs we're done adding file upload jobs
+	close(uploadChan)
+	// wait for all the uploads to be completed
+	<-doneUploading
+	log.Done("all uploads done")
 
-		// after the last upload is done we're done queueing files for deletion
-		deletionQueue.Close()
-		// wait for deletions to be completed before exiting
-		deletionQueue.WaitForWorkers()
-		log.Done("all deletions done")
+	// after the last upload is done we're done queueing files for deletion
+	deletionQueue.Close()
+	// wait for deletions to be completed before exiting
+	deletionQueue.WaitForWorkers()
+	log.Done("all deletions done")
 
-		return nil
+	return nil
 }
diff --git a/cmd/root.go b/cmd/root.go
index d4d61bc..5cfb330 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -1,8 +1,10 @@
 package cmd
 
 import (
+	"fmt"
 	"os"
 
+	"github.com/mgutz/ansi"
 	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 
@@ -16,10 +18,17 @@ var rootCmd = &cobra.Command{
 	SilenceUsage:  true,
 	SilenceErrors: true,
 	Short:         "Welcome to `gphotos-uploader-cli` a Google Photos uploader!",
-	PersistentPreRun: func(cobraCmd *cobra.Command, args []string) {
+	PersistentPreRunE: func(cobraCmd *cobra.Command, args []string) error {
+		if globalFlags.Silent && globalFlags.Debug {
+			return fmt.Errorf("%s and %s cannot be specified at the same time", ansi.Color("--silent", "white+b"), ansi.Color("--debug", "white+b"))
+		}
 		if globalFlags.Silent {
 			log.GetInstance().SetLevel(logrus.FatalLevel)
 		}
+		if globalFlags.Debug {
+			log.GetInstance().SetLevel(logrus.DebugLevel)
+		}
+		return nil
 	},
 	Long: `This application allows you to upload your pictures and videos to Google Photos. You can upload folders to several Google Photos accounts and organize them in albums.
 
diff --git a/log/log.go b/log/log.go
index c898718..7d0ddac 100644
--- a/log/log.go
+++ b/log/log.go
@@ -6,7 +6,7 @@ import (
 )
 
 var defaultLog Logger = &stdoutLogger{
-	level: logrus.DebugLevel,
+	level: logrus.InfoLevel,
 }
 
 // Discard is a logger implementation that just discards every log statement
diff --git a/upload/album.go b/upload/album.go
new file mode 100644
index 0000000..c2277c9
--- /dev/null
+++ b/upload/album.go
@@ -0,0 +1,69 @@
+package upload
+
+import (
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"github.com/gphotosuploader/gphotos-uploader-cli/log"
+)
+
+// albumID returns the album ID if MakeAlbums is enabled
+func (job *Job) albumID(path string, logger log.Logger) string {
+	if !job.options.createAlbum {
+		return ""
+	}
+
+	name := albumNameUsingTemplate(path, job.options.createAlbumBasedOn)
+	if name == "" {
+		return ""
+	}
+
+	albumID, err := job.createAlbumInGPhotos(name)
+	if err != nil {
+		logger.Error(err)
+		return ""
+	}
+	return albumID
+}
+
+// albumNameUsingTemplate calculate the Album name for a given path based on full folder path (folderPath)
+// or folder name (folderName).
+func albumNameUsingTemplate(path, template string) string {
+	switch template {
+	case "folderPath":
+		return albumNameUsingFolderPath(path)
+	case "folderName":
+		return albumNameUsingFolderName(path)
+	}
+	return ""
+}
+
+// albumNameUsingFolderPath returns an Album name using the full path of the given folder.
+func albumNameUsingFolderPath(path string) string {
+	p := filepath.Dir(path)
+	if p == "." {
+		return ""
+	}
+	return strings.ReplaceAll(p, "/", "_")
+}
+
+// albumNameUsingFolderName returns an Album name using the name of the given folder.
+func albumNameUsingFolderName(path string) string {
+	p := filepath.Dir(path)
+	if p == "." {
+		return ""
+	}
+	return filepath.Base(p)
+}
+
+// createAlbumInGPhotos returns the ID of an album with the specified name or error if fails.
+// If the album didn't exist, it's created thanks to GetOrCreateAlbumByName().
+func (job *Job) createAlbumInGPhotos(name string) (string, error) {
+	// get album ID from Google Photos API
+	album, err := job.gPhotos.GetOrCreateAlbumByName(name)
+	if err != nil {
+		return "", fmt.Errorf("album creation failed: name=%s, error=%s", name, err)
+	}
+	return album.Id, nil
+}
diff --git a/upload/filter.go b/upload/filter.go
index a215825..385b27f 100644
--- a/upload/filter.go
+++ b/upload/filter.go
@@ -61,8 +61,6 @@ func NewFilter(includePatterns []string, excludePatterns []string, allowVideos b
 
 // IsAllowed returns if an item should be uploaded.
 // That means:
-//   - item is a file
-//   - item is a not a video if allowVideos is not enabled
 //   - item is in the include pattern
 //   - item is not in the exclude pattern
 func (f *Filter) IsAllowed(fp string) bool {
@@ -70,10 +68,7 @@ func (f *Filter) IsAllowed(fp string) bool {
 	if f.isIncluded(fp) && !f.isExcluded(fp) {
 		return true
 	}
-
-	log.Printf("config doesn't allow to upload this item - skipping: file=%s", fp)
 	return false
-
 }
 
 func translatePatterns(pat []string) []string {
diff --git a/upload/folderUploadJob.go b/upload/folderUploadJob.go
index 9eba2a3..7d16212 100644
--- a/upload/folderUploadJob.go
+++ b/upload/folderUploadJob.go
@@ -1,7 +1,6 @@
 package upload
 
 import (
-	"fmt"
 	"os"
 	"path/filepath"
 
@@ -23,21 +22,19 @@ type Job struct {
 
 // JobOptions represents all the options that a job can have
 type JobOptions struct {
-	createAlbum       bool
-	deleteAfterUpload bool
-	uploadVideos      bool
-	includePatterns   []string
-	excludePatterns   []string
+	createAlbum        bool
+	createAlbumBasedOn string
+	deleteAfterUpload  bool
+	filter             *Filter
 }
 
 // NewJobOptions create a jobOptions based on the submitted / validated data
-func NewJobOptions(createAlbum bool, deleteAfterUpload bool, uploadVideos bool, includePatterns []string, excludePatterns []string) *JobOptions {
+func NewJobOptions(createAlbum bool, createAlbumBasedOn string, deleteAfterUpload bool, filter *Filter) *JobOptions {
 	return &JobOptions{
-		createAlbum:       createAlbum,
-		deleteAfterUpload: deleteAfterUpload,
-		uploadVideos:      uploadVideos,
-		includePatterns:   includePatterns,
-		excludePatterns:   excludePatterns,
+		createAlbum:        createAlbum,
+		createAlbumBasedOn: createAlbumBasedOn,
+		deleteAfterUpload:  deleteAfterUpload,
+		filter:             filter,
 	}
 }
 
@@ -52,88 +49,47 @@ func NewFolderUploadJob(gPhotos *gphotos.Client, fileTracker app.FileTracker, fp
 	}
 }
 
-// ScanFolder uploads folder
-func (job *Job) ScanFolder(uploadChan chan<- *Item, log log.Logger) error {
-	var err error
-
-	if !filesystem.IsDir(job.sourceFolder) {
-		return fmt.Errorf("%s is not a folder", job.sourceFolder)
-	}
-
-	filter := NewFilter(job.options.includePatterns, job.options.excludePatterns, job.options.uploadVideos)
-
-	// dirs are walked depth-first.   These vars hold the active album
-	// default empty album for makeAlbums.enabled = false
-	errW := filepath.Walk(job.sourceFolder, func(fp string, fi os.FileInfo, errP error) error {
-		if err != nil {
-			_, _ = fmt.Fprintf(os.Stderr, "error for %v: %v\n", fp, err)
-			return nil
-		}
-		if fi == nil {
-			_, _ = fmt.Fprintf(os.Stderr, "error for %v: FileInfo is nil\n", fp)
-			return nil
-		}
+// ScanFolder return the list of Items{} to be uploaded. It scans the folder and skip
+// non allowed files (includePatterns & excludePattens).
+func (job *Job) ScanFolder(logger log.Logger) ([]Item, error) {
+	var result []Item
+	err := filepath.Walk(job.sourceFolder, job.createUploadItemListFn(&result, job.options.filter, logger))
+	return result, err
+}
 
-		// check if the item should be uploaded (it's a file and it's not exclude
-		if !filter.IsAllowed(fp) {
-			if fi.IsDir() {
-				return filepath.SkipDir
-			}
+func (job *Job) createUploadItemListFn(items *[]Item, filter *Filter, logger log.Logger) filepath.WalkFunc {
+	return func(fp string, fi os.FileInfo, errP error) error {
+		if fi == nil || fi.IsDir() {
 			return nil
 		}
 
-		// only files are allowed
-		if !filesystem.IsFile(fp) {
+		// check if the item should be uploaded given the include and exclude patterns in the
+		// configuration file. It uses relative path from the source folder path to facilitate
+		// then set up of includePatterns and excludePatterns.
+		relativePath := filesystem.RelativePath(job.sourceFolder, fp)
+		if !filter.IsAllowed(relativePath) {
+			logger.Infof("Not allowed by config: %s: skipping file...", fp)
 			return nil
 		}
 
 		// check completed uploads db for previous uploads
 		isAlreadyUploaded, err := job.fileTracker.IsAlreadyUploaded(fp)
 		if err != nil {
-			log.Error(err)
+			logger.Error(err)
 		} else if isAlreadyUploaded {
-			log.Debugf("Already uploaded: %s: skipping file...", fp)
+			logger.Debugf("Already uploaded: %s: skipping file...", fp)
 			return nil
 		}
 
-		// calculate Album from the folder name, we create if it's not exists
-		var albumID string
-		if job.options.createAlbum {
-			name := filepath.Base(filepath.Dir(fp))
-			albumID = getGooglePhotosAlbumID(name, job.gPhotos, log)
-		}
+		logger.Infof("Adding item to upload: %s", fp)
 
 		// set file upload options depending on folder upload options
-		var uploadItem = &Item{
+		*items = append(*items, Item{
 			gPhotos:         job.gPhotos,
 			path:            fp,
-			album:           albumID,
+			album:           job.albumID(relativePath, logger),
 			deleteOnSuccess: job.options.deleteAfterUpload,
-		}
-
-		// finally, add the file upload to the queue
-		uploadChan <- uploadItem
-
+		})
 		return nil
-	})
-	if errW != nil {
-		log.Errorf("walk error err=%s", errW)
-	}
-
-	return nil
-}
-
-// getGooglePhotosAlbumID returns the ID of an album with the specified name.
-// If the album didn't exist, it's created thanks to GetOrCreateAlbumByName().
-func getGooglePhotosAlbumID(name string, c *gphotos.Client, log log.Logger) string {
-	if name == "" {
-		return ""
-	}
-
-	album, err := c.GetOrCreateAlbumByName(name)
-	if err != nil {
-		log.Warnf("Album creation failed: name=%s, error=%v", name, err)
-		return ""
 	}
-	return album.Id
 }
diff --git a/utils/filesystem/filesystem.go b/utils/filesystem/filesystem.go
index 5851cc3..5041f02 100644
--- a/utils/filesystem/filesystem.go
+++ b/utils/filesystem/filesystem.go
@@ -28,6 +28,20 @@ func AbsolutePath(path string) (string, error) {
 	return filepath.Abs(path)
 }
 
+// RelativePath returns a path relative to the given base path. If the path is not
+// under the given base path, the specified path is returned. So all returned paths
+// are under the base path.
+func RelativePath(basepath, path string) string {
+	rp, err := filepath.Rel(basepath, path)
+	if err != nil {
+		return path
+	}
+	if strings.HasPrefix(rp, "../") {
+		return path
+	}
+	return rp
+}
+
 // IsFile asserts there is a file at path
 func IsFile(path string) bool {
 	fi, err := os.Stat(path)


Test patch:
diff --git a/upload/album_test.go b/upload/album_test.go
new file mode 100644
index 0000000..b396837
--- /dev/null
+++ b/upload/album_test.go
@@ -0,0 +1,63 @@
+package upload
+
+import (
+	"testing"
+)
+
+func TestAlbumNameUsingTemplate(t *testing.T) {
+	var testData = []struct {
+		in       string
+		template string
+		out      string
+	}{
+		{in: "foo/bar/xyz", template: "folderPath", out: "foo_bar"},
+		{in: "foo/bar/xyz", template: "folderName", out: "bar"},
+		{in: "foo/bar/xyz/file", template: "folderPath", out: "foo_bar_xyz"},
+		{in: "foo/bar/xyz/file", template: "folderName", out: "xyz"},
+		{in: "foo/bar/xyz", template: "invalidTemplate", out: ""},
+	}
+	for _, tt := range testData {
+		got := albumNameUsingTemplate(tt.in, tt.template)
+		if got != tt.out {
+			t.Errorf("albumNameUsingTemplate for '%s' failed: in: '%s', expected '%s', got '%s'", tt.template, tt.in, tt.out, got)
+		}
+	}
+}
+
+func TestAlbumNameUsingFolderPath(t *testing.T) {
+	var testData = []struct {
+		in  string
+		out string
+	}{
+		{in: "", out: ""},
+		{in: "foo", out: ""},
+		{in: "foo/", out: "foo"},
+		{in: "foo/bar", out: "foo"},
+		{in: "foo/bar/", out: "foo_bar"},
+	}
+	for _, tt := range testData {
+		got := albumNameUsingFolderPath(tt.in)
+		if got != tt.out {
+			t.Errorf("albumNameUsingFolderPath for '%s' failed: expected '%s', got '%s'", tt.in, tt.out, got)
+		}
+	}
+}
+
+func TestAlbumNameUsingFolderName(t *testing.T) {
+	var testData = []struct {
+		in  string
+		out string
+	}{
+		{in: "", out: ""},
+		{in: "foo", out: ""},
+		{in: "foo/", out: "foo"},
+		{in: "foo/bar", out: "foo"},
+		{in: "foo/bar/", out: "bar"},
+	}
+	for _, tt := range testData {
+		got := albumNameUsingFolderName(tt.in)
+		if got != tt.out {
+			t.Errorf("albumNameUsingFolderName for '%s' failed: expected '%s', got '%s'", tt.in, tt.out, got)
+		}
+	}
+}
diff --git a/utils/filesystem/filesystem_test.go b/utils/filesystem/filesystem_test.go
index c0ad535..f09e060 100644
--- a/utils/filesystem/filesystem_test.go
+++ b/utils/filesystem/filesystem_test.go
@@ -132,3 +132,27 @@ func TestIsDir(t *testing.T) {
 		}
 	}
 }
+
+func TestRelativePath(t *testing.T) {
+	var objectsTest = []struct {
+		base string
+		in   string
+		out  string
+	}{
+		{base: "/foo/bar", in: "/foo/bar/xyz", out: "xyz"},
+		{base: "/foo/bar/", in: "/foo/bar/xyz", out: "xyz"},
+		{base: "/foo/bar", in: "/foo/bar/xyz/", out: "xyz"},
+		{base: "/foo/bar", in: "foo/bar/xyz", out: "foo/bar/xyz"},
+		{base: "/foo/bar", in: "/foo/bar", out: "."},
+		{base: "/foo/bar/", in: "/foo/bar", out: "."},
+		{base: "/foo/bar", in: "/foo/bar/", out: "."},
+		{base: "", in: "/foo/bar", out: "/foo/bar"},
+		{base: "/foo/bar", in: "/abc/def", out: "/abc/def"},
+	}
+	for _, tt := range objectsTest {
+		got := filesystem.RelativePath(tt.base, tt.in)
+		if got != tt.out {
+			t.Errorf("failed for base '%s', path '%s': expected '%s', got '%s'", tt.base, tt.in, tt.out, got)
+		}
+	}
+}