Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for picasa ini files for metadata #430

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions browser/files/localassets.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package files

import (
"context"
"errors"
"io/fs"
"path"
"path/filepath"
Expand All @@ -10,6 +11,7 @@ import (
"time"

"github.com/simulot/immich-go/browser"
"github.com/simulot/immich-go/browser/picasa"
"github.com/simulot/immich-go/helpers/fileevent"
"github.com/simulot/immich-go/helpers/fshelper"
"github.com/simulot/immich-go/helpers/gen"
Expand Down Expand Up @@ -73,6 +75,11 @@ func (la *LocalAssetBrowser) Prepare(ctx context.Context) error {

func (la *LocalAssetBrowser) passOneFsWalk(ctx context.Context, fsys fs.FS) error {
la.catalogs[fsys] = map[string][]string{}
baseDir, ok := fsys.(fshelper.DirFS)
if !ok {
return errors.New("could not cast to fshelper.DirFS")
}

err := fs.WalkDir(fsys, ".",
func(name string, d fs.DirEntry, err error) error {
if err != nil {
Expand All @@ -92,6 +99,15 @@ func (la *LocalAssetBrowser) passOneFsWalk(ctx context.Context, fsys fs.FS) erro
if dir == "" {
dir = "."
}

// TODO Add check for app.Picasa first.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to figure this out.

// But `app` is not available here. so inject(?) it on app.ExploreLocalFolder()
if base == ".picasa.ini" {
picasa.CacheDirectory(filepath.Join(baseDir.Dir(), dir))
la.log.Record(ctx, fileevent.DiscoveredPicasaIni, nil, name)
return nil
}

ext := filepath.Ext(base)
mediaType := la.sm.TypeFromExt(ext)

Expand Down
155 changes: 155 additions & 0 deletions browser/picasa/picasa.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package picasa

import (
"bufio"
"errors"
"github.com/spf13/afero"
"log/slog"
"os"
"path"
"path/filepath"
"strings"
)

type DirectoryData struct {
Name string
Description string
Location string
Files map[string]FileData
Albums map[string]AlbumData
}
type AlbumData struct {
Name string
Description string
Location string
}
type FileData struct {
IsStar bool
Caption string
Albums []string
}

var appFS = afero.NewOsFs()

var DirectoryCache = map[string]DirectoryData{}

func CacheDirectory(dir string) {
DirectoryCache[dir] = ParseDirectory(dir)
}

func HasPicasa(dir string) bool {
fileName := path.Join(dir, ".picasa.ini")
if _, err := os.Stat(fileName); errors.Is(err, os.ErrNotExist) {
return false
}

return true
}

func ParseDirectory(dir string) DirectoryData {
directoryData := DirectoryData{
Files: map[string]FileData{},
Albums: map[string]AlbumData{},
}

iniMap := parseFile(filepath.Join(dir, ".picasa.ini"))
for sectionName, pairs := range iniMap {
if sectionName == "Picasa" {
if value, ok := pairs["name"]; ok {
directoryData.Name = value
}
if value, ok := pairs["description"]; ok {
directoryData.Description = value
}
if value, ok := pairs["location"]; ok {
directoryData.Location = value
}
} else if strings.HasPrefix(sectionName, ".album:") {
albumData := AlbumData{}
token := sectionName[7:]
if value, ok := pairs["name"]; ok {
albumData.Name = value
}
if value, ok := pairs["description"]; ok {
albumData.Description = value
}
if value, ok := pairs["location"]; ok {
albumData.Location = value
}
directoryData.Albums[token] = albumData
} else {
fileData := FileData{}
if value, ok := pairs["star"]; ok {
fileData.IsStar = value == "yes"
}
if value, ok := pairs["caption"]; ok {
fileData.Caption = value
}
if value, ok := pairs["albums"]; ok {
fileData.Albums = strings.Split(value, ",")
}
directoryData.Files[sectionName] = fileData
}
}

return directoryData
}

func parseFile(path string) (result map[string]map[string]string) {
readFile, err := appFS.Open(path)
if err != nil {
slog.Error("could not open file", err)
return
}
defer func() {
err = readFile.Close()
if err != nil {
slog.Error("could not close file", err)
}
}()

fileScanner := bufio.NewScanner(readFile)
return parseScanner(fileScanner)
}

func parseScanner(fileScanner *bufio.Scanner) map[string]map[string]string {
result := map[string]map[string]string{}
fileScanner.Split(bufio.ScanLines)

section := ""
for fileScanner.Scan() {
line := fileScanner.Text()

if len(line) == 0 {
continue
}

if line[0:1] == "[" {
end := strings.Index(line, "]")
section = line[1:end]
if _, ok := result[section]; !ok {
result[section] = map[string]string{}
} else {
panic("unexpected duplicate picasa.ini section. malformed .picasa.ini file?")
}

}
if section != "" {
if eqPos := strings.Index(line, "="); eqPos > 0 {
key := line[0:eqPos]
value := line[eqPos+1:]
result[section][key] = value
}
}
}

return result
}

func (p DirectoryData) DescriptionAndLocation() string {
result := p.Description
if strings.TrimSpace(p.Location) != "" {
result += " Location: " + strings.TrimSpace(p.Location)
}
return result
}
95 changes: 95 additions & 0 deletions browser/picasa/picasa_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package picasa

import (
"bufio"
"fmt"
"github.com/psanford/memfs"
"github.com/spf13/afero"
"reflect"
"strings"
"testing"
)

type inMemFS struct {
*memfs.FS
err error
}

func newInMemFS() *inMemFS {
return &inMemFS{
FS: memfs.New(),
}
}

func Test2(t *testing.T) {
expected := DirectoryData{
Name: "A Name",
Description: "A Description",
Location: "A Location",
Files: map[string]FileData{
"file-name-1.jpg": {},
"file-name-2.jpg": {
IsStar: true,
Caption: "A Caption",
},
},
Albums: map[string]AlbumData{},
}

sample := `
[Picasa]
name=A Name
description=A Description
location=A Location

[file-name-1.jpg]
some_other_key=some value

[file-name-2.jpg]
star=yes
caption=A Caption
`

appFS = afero.NewMemMapFs()
// create test files and directories
appFS.MkdirAll("sample", 0o755)
afero.WriteFile(appFS, "sample/.picasa.ini", []byte(sample), 0o644)

actual := ParseDirectory("sample")

if !reflect.DeepEqual(expected, actual) {
fmt.Printf("%+v\n", expected)
fmt.Printf("%+v\n", actual)
t.Error("ParseDirectory did not yield expected results")
}
}

func TestReadLines(t *testing.T) {
sample := `
[Picasa]
key1=value1
key2=value2

[file-name.jpg]
key3=value3
key4=value4
`
buf := strings.NewReader(sample)
s := bufio.NewScanner(buf)

actual := parseScanner(s)
expected := map[string]map[string]string{
"Picasa": {
"key1": "value1",
"key2": "value2",
},
"file-name.jpg": {
"key3": "value3",
"key4": "value4",
},
}

if !reflect.DeepEqual(actual, expected) {
t.Error("parsed ini did not match expected")
}
}
55 changes: 54 additions & 1 deletion cmd/upload/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"context"
"flag"
"fmt"
"github.com/simulot/immich-go/browser/picasa"
"io/fs"
"log/slog"
"math"
"os"
"path"
Expand Down Expand Up @@ -38,6 +40,7 @@ type UpCmd struct {
GooglePhotos bool // For reading Google Photos takeout files
Delete bool // Delete original file after import
CreateAlbumAfterFolder bool // Create albums for assets based on the parent folder or a given name
Picasa bool // Look for album and image metadata in .picasa.ini files
ImportIntoAlbum string // All assets will be added to this album
PartnerAlbum string // Partner's assets will be added to this album
Import bool // Import instead of upload
Expand Down Expand Up @@ -119,6 +122,10 @@ func newCommand(ctx context.Context, common *cmd.SharedFlags, args []string, fsO
"create-album-folder",
" folder import only: Create albums for assets based on the parent folder",
myflag.BoolFlagFn(&app.CreateAlbumAfterFolder, false))
cmd.BoolFunc(
"picasa",
" folder import only: Use picasa metadata for albums and assets",
myflag.BoolFlagFn(&app.Picasa, false))
cmd.BoolFunc(
"google-photos",
"Import GooglePhotos takeout zip files",
Expand Down Expand Up @@ -451,6 +458,33 @@ func (app *UpCmd) handleAsset(ctx context.Context, a *browser.LocalAssetFile) er
})
}

if app.Picasa {
if rootDir, ok := a.FSys.(fshelper.DirFS); ok {
if picasaDirectoryData, ok := picasa.DirectoryCache[path.Join(rootDir.Dir(), path.Dir(a.FileName))]; ok {
if fileData, ok := picasaDirectoryData.Files[filepath.Base(a.FileName)]; ok {
a.Metadata.Description = fileData.Caption
a.Favorite = fileData.IsStar
for _, token := range fileData.Albums {
if albumData, ok := picasaDirectoryData.Albums[token]; ok {
description := albumData.Description
if albumData.Location != "" {
description = strings.TrimSpace(description + " Location: " + albumData.Location)
}
a.Albums = append(a.Albums, browser.LocalAlbum{
Title: albumData.Name,
Description: description,
})
} else {
// NOTE: .picasa.ini seems to always define the album if it is referenced
// so hopefully, this warning never occurs.
slog.Warn("could not find album: ", token)
}
}
}
}
}
}

advice, err := app.AssetIndex.ShouldUpload(a)
if err != nil {
return err
Expand Down Expand Up @@ -556,16 +590,35 @@ func (app *UpCmd) manageAssetAlbum(ctx context.Context, assetID string, a *brows
} else {
if app.CreateAlbumAfterFolder {
album := path.Base(path.Dir(a.FileName))
description := ""

if album == "" || album == "." {
if fsys, ok := a.FSys.(fshelper.NameFS); ok {
album = fsys.Name()
} else {
album = "no-folder-name"
}
}

if app.Picasa {
if rootDir, ok := a.FSys.(fshelper.DirFS); ok {
if data, ok := picasa.DirectoryCache[path.Join(rootDir.Dir(), path.Dir(a.FileName))]; ok {
if data.Name != "" {
album = data.Name
}
if data.Description != "" {
description = data.Description
}
if data.Location != "" {
description = strings.TrimSpace(data.Description + " Location: " + data.Location)
}
}
}
}

app.Jnl.Record(ctx, fileevent.UploadAddToAlbum, a, a.FileName, "album", album, "reason", "option -create-album-folder")
if !app.DryRun {
err := app.AddToAlbum(ctx, assetID, browser.LocalAlbum{Title: album})
err := app.AddToAlbum(ctx, assetID, browser.LocalAlbum{Title: album, Description: description})
if err != nil {
app.Jnl.Record(ctx, fileevent.Error, a, a.FileName, "error", err.Error())
}
Expand Down
Loading