Skip to content
Merged
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
36 changes: 36 additions & 0 deletions pkg/stacker/duration_extractor_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
2 changes: 1 addition & 1 deletion pkg/stacker/stacker_extractors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down
32 changes: 31 additions & 1 deletion pkg/utils/types.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.)
}
Expand Down
97 changes: 97 additions & 0 deletions pkg/utils/types_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading