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

feat: integrate BulkTagManager for improved asset tag management #653

Merged
merged 9 commits into from
Jan 25, 2025
53 changes: 34 additions & 19 deletions app/cmd/upload/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/simulot/immich-go/app"
"github.com/simulot/immich-go/immich"
"github.com/simulot/immich-go/internal/assets"
"github.com/simulot/immich-go/internal/bulktags"
"github.com/simulot/immich-go/internal/fileevent"
"github.com/simulot/immich-go/internal/filters"
"github.com/simulot/immich-go/internal/fshelper"
Expand All @@ -30,6 +31,8 @@ type UpCmd struct {
Paths []string // Path to explore
albums map[string]assets.Album // Albums by title

tm *bulktags.BulkTagManager // Bulk tag manager

takeoutOptions *gp.ImportFlags
}

Expand All @@ -49,6 +52,8 @@ func (upCmd *UpCmd) setTakeoutOptions(options *gp.ImportFlags) *UpCmd {

func (upCmd *UpCmd) run(ctx context.Context, adapter adapters.Reader, app *app.Application) error {
upCmd.adapter = adapter
upCmd.tm = bulktags.NewBulkTagManager(ctx, app.Client().Immich, app.Log().Logger)
defer upCmd.tm.Close()

if upCmd.NoUI {
return upCmd.runNoUI(ctx, app)
Expand Down Expand Up @@ -207,6 +212,27 @@ func (upCmd *UpCmd) handleAsset(ctx context.Context, a *assets.Asset) error {
return err
}

// If the asset exists on the server, at full size, or smaller, we should get its tags and not tag it again.
if advice.ServerAsset != nil {
serverAsset, err := upCmd.app.Client().Immich.GetAssetInfo(ctx, advice.ServerAsset.ID)
if err == nil {
newList := []assets.Tag{}
for _, t := range a.Tags {
keepMe := true
for _, st := range serverAsset.Tags {
if t.Name == st.Name {
keepMe = false
break
}
}
if keepMe {
newList = append(newList, t)
}
}
a.Tags = newList
}
}

switch advice.Advice {
case NotOnServer: // Upload and manage albums
err = upCmd.uploadAsset(ctx, a)
Expand Down Expand Up @@ -277,10 +303,10 @@ func (upCmd *UpCmd) handleAsset(ctx context.Context, a *assets.Asset) error {
return err
}

// err = upCmd.manageAssetTags(ctx, a)
// if err != nil {
// return err
// }
err = upCmd.manageAssetTags(ctx, a)
if err != nil {
return err
}
}
return nil
}
Expand Down Expand Up @@ -362,24 +388,13 @@ func (upCmd *UpCmd) manageAssetAlbums(ctx context.Context, f fshelper.FSAndName,
}
}

func (upCmd *UpCmd) manageAssetTags(ctx context.Context, a *assets.Asset) error { // nolint
func (upCmd *UpCmd) manageAssetTags(ctx context.Context, a *assets.Asset) error {
if len(a.Tags) > 0 {
ss := []string{}
// Get asset's tags
for _, t := range a.Tags {
tags, err := upCmd.app.Client().Immich.UpsertTags(ctx, []string{t.Value})
if err != nil {
upCmd.app.Jnl().Record(ctx, fileevent.Error, a.File, "error", err.Error())
continue
}
for _, t := range tags {
_, err = upCmd.app.Client().Immich.TagAssets(ctx, t.ID, []string{a.ID})
if err != nil {
upCmd.app.Jnl().Record(ctx, fileevent.Error, a.File, "error", err.Error())
}
ss = append(ss, t.Value)
}
upCmd.tm.AddTag(t.Name, a.ID)
upCmd.app.Jnl().Record(ctx, fileevent.Tagged, a.File, "tags", t.Name)
}
upCmd.app.Jnl().Record(ctx, fileevent.Tagged, a.File, "tags", ss)
}
return nil
}
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,20 @@ require (
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
github.com/samber/slog-multi v1.3.3
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.10.0
github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8
golang.org/x/sync v0.10.0
)

require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/samber/lo v1.47.0 // indirect
Expand Down
82 changes: 82 additions & 0 deletions immich/asset.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,90 @@ import (

"github.com/google/uuid"
"github.com/simulot/immich-go/internal/assets"
"github.com/simulot/immich-go/internal/fshelper"
)

// immich Asset simplified
type Asset struct {
ID string `json:"id"`
DeviceAssetID string `json:"deviceAssetId"`
OwnerID string `json:"ownerId"`
DeviceID string `json:"deviceId"`
Type string `json:"type"`
OriginalPath string `json:"originalPath"`
OriginalFileName string `json:"originalFileName"`
Resized bool `json:"resized"`
Thumbhash string `json:"thumbhash"`
FileCreatedAt ImmichTime `json:"fileCreatedAt"`
FileModifiedAt ImmichTime `json:"fileModifiedAt"`
UpdatedAt ImmichTime `json:"updatedAt"`
IsFavorite bool `json:"isFavorite"`
IsArchived bool `json:"isArchived"`
IsTrashed bool `json:"isTrashed"`
Duration string `json:"duration"`
Rating int `json:"rating"`
ExifInfo ExifInfo `json:"exifInfo"`
LivePhotoVideoID string `json:"livePhotoVideoId"`
Checksum string `json:"checksum"`
StackParentID string `json:"stackParentId"`
Albums []AlbumSimplified `json:"-"` // Albums that asset belong to
Tags []TagSimplified `json:"tags"`
// JustUploaded bool `json:"-"` // TO REMOVE
}

// NewAssetFromImmich creates an assets.Asset from an immich.Asset.
func (ia Asset) AsAsset() *assets.Asset {
a := &assets.Asset{
FileDate: ia.FileModifiedAt.Time,
Description: ia.ExifInfo.Description,
OriginalFileName: ia.OriginalFileName,
ID: ia.ID,
CaptureDate: ia.ExifInfo.DateTimeOriginal.Time,
Trashed: ia.IsTrashed,
Archived: ia.IsArchived,
Favorite: ia.IsFavorite,
Rating: ia.Rating,
Latitude: ia.ExifInfo.Latitude,
Longitude: ia.ExifInfo.Longitude,
File: fshelper.FSName(nil, ia.OriginalFileName),
}
a.FileSize = int(ia.ExifInfo.FileSizeInByte)
for _, album := range ia.Albums {
a.Albums = append(a.Albums, assets.Album{
Title: album.AlbumName,
Description: album.Description,
})
}

for _, tag := range ia.Tags {
a.Tags = append(a.Tags, tag.AsTag())
}
return a
}

type ExifInfo struct {
Make string `json:"make"`
Model string `json:"model"`
ExifImageWidth int `json:"exifImageWidth"`
ExifImageHeight int `json:"exifImageHeight"`
FileSizeInByte int64 `json:"fileSizeInByte"`
Orientation string `json:"orientation"`
DateTimeOriginal ImmichTime `json:"dateTimeOriginal,omitempty"`
// ModifyDate time.Time `json:"modifyDate"`
TimeZone string `json:"timeZone"`
// LensModel string `json:"lensModel"`
// FNumber float64 `json:"fNumber"`
// FocalLength float64 `json:"focalLength"`
// Iso int `json:"iso"`
// ExposureTime string `json:"exposureTime"`
Latitude float64 `json:"latitude,omitempty"`
Longitude float64 `json:"longitude,omitempty"`
// City string `json:"city"`
// State string `json:"state"`
// Country string `json:"country"`
Description string `json:"description"`
}

type AssetResponse struct {
ID string `json:"id"`
Status string `json:"status"`
Expand Down
66 changes: 45 additions & 21 deletions immich/call.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const (
EndPointUpsertTags = "UpsertTags"
EndPointTagAssets = "TagAssets"
EndPointBulkTagAssets = "BulkTagAssets"
EndPointGetAllTags = "GetAllTags"
)

type TooManyInternalError struct {
Expand All @@ -60,14 +61,14 @@ type callError struct {
url string
status int
err error
message *ServerMessage
message *ServerErrorMessage
}

type ServerMessage struct {
Error string `json:"error"`
StatusCode int `json:"statusCode"`
Message []string `json:"message"`
CorrelationID string `json:"correlationId"`
type ServerErrorMessage struct {
Error string `json:"error"`
StatusCode int `json:"statusCode"`
Message string `json:"message"`
CorrelationID string `json:"correlationId"`
}

func (ce callError) Is(target error) bool {
Expand All @@ -91,18 +92,22 @@ func (ce callError) Error() string {
b.WriteString(ce.err.Error())
b.WriteRune('\n')
}
if ce.message != nil {
if ce.message.Error != "" {
b.WriteString(ce.message.Error)
b.WriteRune('\n')
}
if len(ce.message.Message) > 0 {
for _, m := range ce.message.Message {
b.WriteString(m)
b.WriteRune('\n')
}
}
}
// if ce.message != nil {
// if ce.message.Error != "" {
// b.WriteString(ce.message.Error)
// b.WriteRune('\n')
// }

// if len(ce.message.Message) > 0 {
// for _, m := range ce.message.Message {
// b.WriteString(m)
// b.WriteRune('\n')
// }
// }
// }
b.WriteString(ce.message.Message)
b.WriteRune('\n')

return b.String()
}

Expand All @@ -115,7 +120,7 @@ func (ic *ImmichClient) newServerCall(ctx context.Context, api string) *serverCa
return sc
}

func (sc *serverCall) Err(req *http.Request, resp *http.Response, msg *ServerMessage) error {
func (sc *serverCall) Err(req *http.Request, resp *http.Response, msg *ServerErrorMessage) error {
ce := callError{
endPoint: sc.endPoint,
err: sc.err,
Expand Down Expand Up @@ -227,10 +232,12 @@ func (sc *serverCall) do(fnRequest requestFunction, opts ...serverResponseOption

// Any StatusCode above 300 denotes a problem
if resp.StatusCode >= 300 {
msg := ServerMessage{}
msg := ServerErrorMessage{}
if resp.Body != nil {
defer resp.Body.Close()
if json.NewDecoder(resp.Body).Decode(&msg) == nil {
b := bytes.NewBuffer(nil)
_, _ = io.Copy(b, resp.Body)
if json.NewDecoder(b).Decode(&msg) == nil {
if sc.ic.apiTraceWriter != nil && sc.endPoint != EndPointGetJobs {
seq := sc.ctx.Value(ctxCallSequenceID)
fmt.Fprintln(
Expand All @@ -252,6 +259,23 @@ func (sc *serverCall) do(fnRequest requestFunction, opts ...serverResponseOption
fmt.Fprint(sc.ic.apiTraceWriter, "-- response body end --\n\n")
}
return sc.Err(req, resp, &msg)
} else {
if sc.ic.apiTraceWriter != nil && sc.endPoint != EndPointGetJobs {
seq := sc.ctx.Value(ctxCallSequenceID)
fmt.Fprintln(
sc.ic.apiTraceWriter,
time.Now().Format(time.RFC3339),
"RESPONSE",
seq,
sc.endPoint,
resp.Request.Method,
resp.Request.URL.String(),
)
fmt.Fprintln(sc.ic.apiTraceWriter, " Status:", resp.Status)
fmt.Fprintln(sc.ic.apiTraceWriter, "-- response body --")
fmt.Fprintln(sc.ic.apiTraceWriter, b.String())
fmt.Fprint(sc.ic.apiTraceWriter, "-- response body end --\n\n")
}
}
}
return sc.Err(req, resp, &msg)
Expand Down
Loading