diff --git a/pkg/stacker/duration_extractor_test.go b/pkg/stacker/duration_extractor_test.go new file mode 100644 index 0000000..2c15b8f --- /dev/null +++ b/pkg/stacker/duration_extractor_test.go @@ -0,0 +1,36 @@ +package stacker + +import ( + "encoding/json" + "testing" + + "github.com/majorfi/immich-stack/pkg/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDurationExtractor(t *testing.T) { + extractor, ok := getExtractor("duration") + require.True(t, ok, "duration extractor must be registered") + + tests := []struct { + name string + body string + want string + }{ + {name: "v2 string", body: `{"id":"1","duration":"0:00:12.500000"}`, want: "0:00:12.500000"}, + {name: "v3 number", body: `{"id":"2","duration":1250}`, want: "1250"}, + {name: "v3 null", body: `{"id":"3","duration":null}`, want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var a utils.TAsset + require.NoError(t, json.Unmarshal([]byte(tt.body), &a)) + + got, err := extractor(a, utils.TCriteria{}) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/stacker/stacker_extractors.go b/pkg/stacker/stacker_extractors.go index 52c0988..2a06b75 100644 --- a/pkg/stacker/stacker_extractors.go +++ b/pkg/stacker/stacker_extractors.go @@ -14,7 +14,7 @@ var extractors = map[string]func(asset utils.TAsset, c utils.TCriteria) (string, "id": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.ID, nil }, "deviceAssetId": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.DeviceAssetID, nil }, "deviceId": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.DeviceID, nil }, - "duration": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return a.Duration, nil }, + "duration": func(a utils.TAsset, _ utils.TCriteria) (string, error) { return string(a.Duration), nil }, "fileCreatedAt": func(a utils.TAsset, c utils.TCriteria) (string, error) { return extractTimeWithDelta(a.FileCreatedAt, c.Delta) }, diff --git a/pkg/utils/types.go b/pkg/utils/types.go index 22ac980..bb2e7d4 100644 --- a/pkg/utils/types.go +++ b/pkg/utils/types.go @@ -1,5 +1,35 @@ package utils +import ( + "bytes" + "encoding/json" +) + +// Duration accepts Immich's asset duration as a string (<= v2) or a number of ms (v3, issue #69). +type Duration string + +func (d *Duration) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 || string(data) == "null" { + *d = "" + return nil + } + if data[0] == '"' { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *d = Duration(s) + return nil + } + var n json.Number + if err := json.Unmarshal(data, &n); err != nil { + return err + } + *d = Duration(n.String()) + return nil +} + /************************************************************************************************** ** TDelta represents a time delta configuration for comparing time-based values. ** It allows for a buffer when comparing timestamps. @@ -83,7 +113,7 @@ type TAsset struct { Type string `json:"type"` // Asset type UpdatedAt string `json:"updatedAt"` // Last update time Checksum string `json:"checksum"` // File checksum - Duration string `json:"duration"` // Duration (for videos) + Duration Duration `json:"duration"` // Duration; string on Immich <= v2, ms number on v3 Stack *TStack `json:"stack,omitempty"` // Associated stack if any ExifInfo *TExifInfo `json:"exifInfo,omitempty"` // Optional EXIF metadata (size, etc.) } diff --git a/pkg/utils/types_test.go b/pkg/utils/types_test.go new file mode 100644 index 0000000..c569c2b --- /dev/null +++ b/pkg/utils/types_test.go @@ -0,0 +1,97 @@ +package utils + +import ( + "encoding/json" + "testing" +) + +func TestDurationUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + want Duration + wantErr bool + }{ + {name: "v2 string zero", input: `"0:00:00.000000"`, want: "0:00:00.000000"}, + {name: "v2 string non-zero", input: `"0:00:12.500000"`, want: "0:00:12.500000"}, + {name: "v3 number ms", input: `1250`, want: "1250"}, + {name: "v3 number zero", input: `0`, want: "0"}, + {name: "v3 number large", input: `32420`, want: "32420"}, + {name: "v3 number fractional", input: `1250.5`, want: "1250.5"}, + {name: "null", input: `null`, want: ""}, + {name: "empty string", input: `""`, want: ""}, + {name: "bool is invalid", input: `true`, wantErr: true}, + {name: "object is invalid", input: `{}`, wantErr: true}, + {name: "array is invalid", input: `[1]`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var d Duration + err := json.Unmarshal([]byte(tt.input), &d) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error unmarshaling %q, got none (value=%q)", tt.input, d) + } + return + } + if err != nil { + t.Fatalf("unexpected error unmarshaling %q: %v", tt.input, err) + } + if d != tt.want { + t.Fatalf("unmarshaling %q = %q, want %q", tt.input, d, tt.want) + } + }) + } +} + +func TestTAssetDurationV2AndV3(t *testing.T) { + tests := []struct { + name string + body string + wantDuration Duration + }{ + {name: "v2 image string duration", body: `{"id":"a1","type":"IMAGE","duration":"0:00:00.000000"}`, wantDuration: "0:00:00.000000"}, + {name: "v3 image null duration", body: `{"id":"a2","type":"IMAGE","duration":null}`, wantDuration: ""}, + {name: "v3 animated gif number duration", body: `{"id":"a3","type":"IMAGE","duration":1250}`, wantDuration: "1250"}, + {name: "v3 video number duration", body: `{"id":"a4","type":"VIDEO","duration":32420}`, wantDuration: "32420"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var a TAsset + if err := json.Unmarshal([]byte(tt.body), &a); err != nil { + t.Fatalf("decoding asset: %v", err) + } + if a.Duration != tt.wantDuration { + t.Fatalf("Duration = %q, want %q", a.Duration, tt.wantDuration) + } + }) + } +} + +func TestSearchResponseDecodeMixedDurations(t *testing.T) { + body := `{"assets":{"items":[ + {"id":"1","type":"IMAGE","duration":null}, + {"id":"2","type":"IMAGE","duration":"0:00:00.000000"}, + {"id":"3","type":"IMAGE","duration":1250}, + {"id":"4","type":"VIDEO","duration":32420} + ],"nextPage":"2"}}` + + var resp TSearchResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("decoding search response: %v", err) + } + if got := len(resp.Assets.Items); got != 4 { + t.Fatalf("decoded %d items, want 4", got) + } + want := []Duration{"", "0:00:00.000000", "1250", "32420"} + for i, w := range want { + if resp.Assets.Items[i].Duration != w { + t.Fatalf("item %d Duration = %q, want %q", i, resp.Assets.Items[i].Duration, w) + } + } + if resp.Assets.NextPage != "2" { + t.Fatalf("NextPage = %q, want %q", resp.Assets.NextPage, "2") + } +}