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
11 changes: 11 additions & 0 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ var withDeleted bool
var logLevel string
var logFormat string
var removeSingleAssetStacks bool
var includeVideos bool
var includeVideosFlagSet bool
var filterAlbumIDs []string
var filterTakenAfter string
var filterTakenBefore string
Expand Down Expand Up @@ -153,6 +155,7 @@ func logStartupSummary(logger *logrus.Logger) {
"withArchived": withArchived,
"withDeleted": withDeleted,
"removeSingleAssetStacks": removeSingleAssetStacks,
"includeVideos": includeVideos,
"criteria": criteria,
"parentFilenamePromote": parentFilenamePromote,
"parentExtPromote": parentExtPromote,
Expand Down Expand Up @@ -197,6 +200,9 @@ func logStartupSummary(logger *logrus.Logger) {
if removeSingleAssetStacks {
summary = append(summary, "remove-single=true")
}
if includeVideos {
summary = append(summary, "include-videos=true")
}
if criteria != "" {
summary = append(summary, fmt.Sprintf("criteria=%s", criteria))
}
Expand Down Expand Up @@ -289,6 +295,11 @@ func LoadEnvForTesting() LoadEnvConfig {
if !removeSingleAssetStacks {
removeSingleAssetStacks = os.Getenv("REMOVE_SINGLE_ASSET_STACKS") == "true"
}
if !includeVideosFlagSet {
if envInclude := os.Getenv("INCLUDE_VIDEOS"); envInclude != "" {
includeVideos = envInclude == "true"
}
}
if parentFilenamePromote == "" || parentFilenamePromote == utils.DefaultParentFilenamePromoteString {
if envVal := os.Getenv("PARENT_FILENAME_PROMOTE"); envVal != "" {
parentFilenamePromote = envVal
Expand Down
2 changes: 1 addition & 1 deletion cmd/duplicates.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func runDuplicates(cmd *cobra.Command, args []string) {
if i > 0 {
logger.Infof("\n")
}
client := immich.NewClient(apiURL, key, false, false, true, withArchived, withDeleted, false, nil, "", "", logger)
client := immich.NewClient(apiURL, key, false, false, true, withArchived, withDeleted, false, includeVideos, nil, "", "", logger)
if client == nil {
logger.Errorf("Invalid client for API key: %s", key)
continue
Expand Down
2 changes: 1 addition & 1 deletion cmd/fixtrash.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func runFixTrash(cmd *cobra.Command, args []string) {
if i > 0 {
logger.Infof("\n")
}
client := immich.NewClient(apiURL, key, false, false, dryRun, withArchived, withDeleted, false, nil, "", "", logger)
client := immich.NewClient(apiURL, key, false, false, dryRun, withArchived, withDeleted, false, includeVideos, nil, "", "", logger)
if client == nil {
logger.Errorf("Invalid client for API key: %s", key)
continue
Expand Down
4 changes: 4 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func bindFlags(rootCmd *cobra.Command) {
rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "", "Log level: debug, info, warn, error (or set LOG_LEVEL env var)")
rootCmd.PersistentFlags().StringVar(&logFormat, "log-format", "", "Log format: text, json (or set LOG_FORMAT env var)")
rootCmd.PersistentFlags().BoolVar(&removeSingleAssetStacks, "remove-single-asset-stacks", false, "Remove stacks with only one asset (or set REMOVE_SINGLE_ASSET_STACKS=true)")
rootCmd.PersistentFlags().BoolVar(&includeVideos, "include-videos", false, "Include VIDEO assets alongside IMAGE in stacking (or set INCLUDE_VIDEOS=true)")
rootCmd.PersistentFlags().StringSliceVar(&filterAlbumIDs, "filter-album-ids", nil, "Filter by album IDs or names, comma-separated (or set FILTER_ALBUM_IDS env var)")
rootCmd.PersistentFlags().StringVar(&filterTakenAfter, "filter-taken-after", "", "Filter assets taken after date, ISO 8601 (or set FILTER_TAKEN_AFTER env var)")
rootCmd.PersistentFlags().StringVar(&filterTakenBefore, "filter-taken-before", "", "Filter assets taken before date, ISO 8601 (or set FILTER_TAKEN_BEFORE env var)")
Expand Down Expand Up @@ -82,6 +83,9 @@ func CreateRootCommand() *cobra.Command {
if cmd.Flags().Lookup("replace-stacks") != nil && cmd.Flags().Lookup("replace-stacks").Changed {
replaceStacksFlagSet = true
}
if cmd.Flags().Lookup("include-videos") != nil && cmd.Flags().Lookup("include-videos").Changed {
includeVideosFlagSet = true
}
},
}

Expand Down
4 changes: 2 additions & 2 deletions cmd/stacker.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ func runStacker(cmd *cobra.Command, args []string) {
if i > 0 {
logger.Infof("\n")
}
client := immich.NewClient(apiURL, key, resetStacks, replaceStacks, dryRun, withArchived, withDeleted, removeSingleAssetStacks, filterAlbumIDs, filterTakenAfter, filterTakenBefore, logger)
client := immich.NewClient(apiURL, key, resetStacks, replaceStacks, dryRun, withArchived, withDeleted, removeSingleAssetStacks, includeVideos, filterAlbumIDs, filterTakenAfter, filterTakenBefore, logger)
if client == nil {
logger.Errorf("Invalid client for API key: %s", key)
continue
Expand Down Expand Up @@ -346,7 +346,7 @@ func runCronLoopForAllUsers(apiKeys []string, apiURL string, logger *logrus.Logg
if i > 0 {
logger.Infof("\n")
}
client := immich.NewClient(apiURL, key, resetStacks, replaceStacks, dryRun, withArchived, withDeleted, removeSingleAssetStacks, filterAlbumIDs, filterTakenAfter, filterTakenBefore, logger)
client := immich.NewClient(apiURL, key, resetStacks, replaceStacks, dryRun, withArchived, withDeleted, removeSingleAssetStacks, includeVideos, filterAlbumIDs, filterTakenAfter, filterTakenBefore, logger)
if client == nil {
logger.Errorf("Invalid client for API key: %s", key)
continue
Expand Down
2 changes: 2 additions & 0 deletions cmd/stacker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ func resetGlobalConfig() {
withDeleted = false
logLevel = ""
removeSingleAssetStacks = false
includeVideos = false
includeVideosFlagSet = false
}

func clearEnvironment() {
Expand Down
41 changes: 41 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,47 @@ timeline` disabled.

This resolves [issue #55](https://github.com/Majorfi/immich-stack/issues/55).

### Stacking Video Files

**Symptoms:**

- Stacking criteria match but `.mov`/`.mp4`/other video files are never picked up
- Live Photos (`.HEIC` + `.MOV` pairs) only stack the photo, not the motion file
- Edited videos (trimmed, cropped) cannot be stacked with their originals

**Cause:**

By default, immich-stack restricts `/search/metadata` calls to `type=IMAGE`. Video assets
are excluded from the candidate pool entirely, regardless of whether your stacking criteria
would otherwise match them.

**Solution:**

Enable `INCLUDE_VIDEOS=true` (or the CLI flag `--include-videos`). When set, every asset
fetch runs twice — once for `IMAGE` and once for `VIDEO` — and results are deduplicated.
Existing stacking criteria (filename patterns, time deltas, regex, etc.) work on videos
the same way they work on images.

```sh
INCLUDE_VIDEOS=true
```

**Examples of use cases this unlocks:**

- iPhone Live Photos: `IMG_1234.HEIC` paired with `IMG_1234.MOV` (same base name)
- Trimmed/edited videos paired with the original file
- Android burst videos with identical timestamps

**Performance implications:**

- A second pagination round runs for VIDEO. Wall-clock latency increases by the time the
VIDEO scan takes — proportional to how many videos you have. A library that's mostly
images sees a small bump; a library with many videos sees more.
- The `OTHER` and `AUDIO` Immich types are still excluded — only `IMAGE` and `VIDEO` are
pulled in.

This resolves [issue #54](https://github.com/Majorfi/immich-stack/issues/54).

### Grouping Issues

**Symptoms:**
Expand Down
188 changes: 107 additions & 81 deletions pkg/immich/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ type Client struct {
withArchived bool
withDeleted bool
removeSingleAssetStacks bool
includeVideos bool
filterAlbumIDs []string
filterTakenAfter string
filterTakenBefore string
Expand All @@ -89,13 +90,14 @@ type Client struct {
** @param withArchived - Whether to include archived assets
** @param withDeleted - Whether to include deleted assets
** @param removeSingleAssetStacks - Whether to remove stacks with only one asset
** @param includeVideos - Whether to include VIDEO assets alongside IMAGE in searches
** @param filterAlbumIDs - Filter by album IDs (empty slice means no filter)
** @param filterTakenAfter - Filter assets taken after this date (empty means no filter)
** @param filterTakenBefore - Filter assets taken before this date (empty means no filter)
** @param logger - Logger instance for output
** @return *Client - Configured Immich client instance
**************************************************************************************************/
func NewClient(apiURL, apiKey string, resetStacks bool, replaceStacks bool, dryRun bool, withArchived bool, withDeleted bool, removeSingleAssetStacks bool, filterAlbumIDs []string, filterTakenAfter string, filterTakenBefore string, logger *logrus.Logger) *Client {
func NewClient(apiURL, apiKey string, resetStacks bool, replaceStacks bool, dryRun bool, withArchived bool, withDeleted bool, removeSingleAssetStacks bool, includeVideos bool, filterAlbumIDs []string, filterTakenAfter string, filterTakenBefore string, logger *logrus.Logger) *Client {
Comment thread
Majorfi marked this conversation as resolved.
if apiKey == "" {
return nil
}
Expand Down Expand Up @@ -134,6 +136,7 @@ func NewClient(apiURL, apiKey string, resetStacks bool, replaceStacks bool, dryR
withArchived: withArchived,
withDeleted: withDeleted,
removeSingleAssetStacks: removeSingleAssetStacks,
includeVideos: includeVideos,
filterAlbumIDs: filterAlbumIDs,
filterTakenAfter: filterTakenAfter,
filterTakenBefore: filterTakenBefore,
Expand Down Expand Up @@ -297,6 +300,22 @@ func (c *Client) FetchAllStacks() (map[string]utils.TStack, error) {
return stacksMap, nil
}

/**************************************************************************************************
** assetTypesForSearch returns the asset types to enumerate when calling /search/metadata.
** Defaults to IMAGE only (historical behavior). When includeVideos is enabled, returns
** [IMAGE, VIDEO] so the caller can run the same pagination twice and deduplicate, since the
** Immich search endpoint only accepts a single type per request.
**
** Returned values must always be non-empty Immich AssetTypeEnum values (IMAGE, VIDEO, AUDIO,
** or OTHER). An empty string would make the server return ALL types — never return one.
**************************************************************************************************/
func (c *Client) assetTypesForSearch() []string {
if c.includeVideos {
return []string{"IMAGE", "VIDEO"}
}
return []string{"IMAGE"}
}

/**************************************************************************************************
** FetchAssets retrieves all assets from Immich with pagination support.
** Assets are enriched with their stack information if available.
Expand Down Expand Up @@ -349,63 +368,65 @@ func (c *Client) FetchAssets(size int, stacksMap map[string]utils.TStack) ([]uti
seen := make(map[string]bool)
var allAssets []utils.TAsset

for _, albumFilter := range albumFilters {
page := 1
for {
if len(albumFilter) > 0 {
c.logger.Debugf("Fetching page %d for album(s) %v", page, albumFilter)
} else {
c.logger.Debugf("Fetching page %d", page)
}
var response utils.TSearchResponse
for _, assetType := range c.assetTypesForSearch() {
for _, albumFilter := range albumFilters {
page := 1
for {
if len(albumFilter) > 0 {
c.logger.Debugf("Fetching page %d (%s) for album(s) %v", page, assetType, albumFilter)
} else {
c.logger.Debugf("Fetching page %d (%s)", page, assetType)
}
var response utils.TSearchResponse

payload := map[string]interface{}{
"size": size,
"page": page,
"order": "asc",
"type": assetType,
"isVisible": true,
"withStacked": true,
"withArchived": c.withArchived,
"withDeleted": c.withDeleted,
}
if len(albumFilter) > 0 {
payload["albumIds"] = albumFilter
}
if c.filterTakenAfter != "" {
payload["takenAfter"] = c.filterTakenAfter
}
if c.filterTakenBefore != "" {
payload["takenBefore"] = c.filterTakenBefore
}

payload := map[string]interface{}{
"size": size,
"page": page,
"order": "asc",
"type": "IMAGE",
"isVisible": true,
"withStacked": true,
"withArchived": c.withArchived,
"withDeleted": c.withDeleted,
}
if len(albumFilter) > 0 {
payload["albumIds"] = albumFilter
}
if c.filterTakenAfter != "" {
payload["takenAfter"] = c.filterTakenAfter
}
if c.filterTakenBefore != "" {
payload["takenBefore"] = c.filterTakenBefore
}
if err := c.doRequest(http.MethodPost, "/search/metadata", payload, &response); err != nil {
c.logger.Errorf("Error fetching assets: %v", err)
return nil, fmt.Errorf("error fetching assets: %w", err)
}

if err := c.doRequest(http.MethodPost, "/search/metadata", payload, &response); err != nil {
c.logger.Errorf("Error fetching assets: %v", err)
return nil, fmt.Errorf("error fetching assets: %w", err)
}
// Enrich assets with stack information and deduplicate
for i := range response.Assets.Items {
asset := &response.Assets.Items[i]
if seen[asset.ID] {
continue
}
seen[asset.ID] = true
if stack, ok := stacksMap[asset.ID]; ok {
asset.Stack = &stack
}
allAssets = append(allAssets, *asset)
}

// Enrich assets with stack information and deduplicate
for i := range response.Assets.Items {
asset := &response.Assets.Items[i]
if seen[asset.ID] {
continue
// Handle string nextPage: empty string means no more pages
if response.Assets.NextPage == "" || response.Assets.NextPage == "0" {
break
}
seen[asset.ID] = true
if stack, ok := stacksMap[asset.ID]; ok {
asset.Stack = &stack
nextPageInt, err := strconv.Atoi(response.Assets.NextPage)
if err != nil || nextPageInt == 0 {
break
}
allAssets = append(allAssets, *asset)
}

// Handle string nextPage: empty string means no more pages
if response.Assets.NextPage == "" || response.Assets.NextPage == "0" {
break
}
nextPageInt, err := strconv.Atoi(response.Assets.NextPage)
if err != nil || nextPageInt == 0 {
break
page = nextPageInt
}
page = nextPageInt
}
}

Expand Down Expand Up @@ -526,42 +547,47 @@ func (c *Client) GetCurrentUser() (utils.TUserResponse, error) {
**************************************************************************************************/
func (c *Client) FetchTrashedAssets(size int) ([]utils.TAsset, error) {
var allTrashedAssets []utils.TAsset
page := 1
seen := make(map[string]bool)

c.logger.Debugf("🗑️ Fetching trashed assets:")
for {
c.logger.Debugf("Fetching trashed assets page %d", page)
var response utils.TSearchResponse
if err := c.doRequest(http.MethodPost, "/search/metadata", map[string]interface{}{
"size": size,
"page": page,
"order": "asc",
"type": "IMAGE",
"isVisible": true,
"withStacked": true,
"withArchived": false,
"withDeleted": true,
}, &response); err != nil {
c.logger.Errorf("Error fetching trashed assets: %v", err)
return nil, fmt.Errorf("error fetching trashed assets: %w", err)
}
for _, assetType := range c.assetTypesForSearch() {
page := 1
for {
c.logger.Debugf("Fetching trashed assets page %d (%s)", page, assetType)
var response utils.TSearchResponse
if err := c.doRequest(http.MethodPost, "/search/metadata", map[string]interface{}{
"size": size,
"page": page,
"order": "asc",
"type": assetType,
"isVisible": true,
"withStacked": true,
"withArchived": false,
"withDeleted": true,
}, &response); err != nil {
c.logger.Errorf("Error fetching trashed assets: %v", err)
return nil, fmt.Errorf("error fetching trashed assets: %w", err)
}

// Filter for only trashed assets
for _, asset := range response.Assets.Items {
if asset.IsTrashed {
// Filter for only trashed assets, deduplicating across type passes
for _, asset := range response.Assets.Items {
if !asset.IsTrashed || seen[asset.ID] {
continue
}
seen[asset.ID] = true
allTrashedAssets = append(allTrashedAssets, asset)
}
}

// Handle string nextPage: empty string means no more pages
if response.Assets.NextPage == "" || response.Assets.NextPage == "0" {
break
}
nextPageInt, err := strconv.Atoi(response.Assets.NextPage)
if err != nil || nextPageInt == 0 {
break
// Handle string nextPage: empty string means no more pages
if response.Assets.NextPage == "" || response.Assets.NextPage == "0" {
break
}
nextPageInt, err := strconv.Atoi(response.Assets.NextPage)
if err != nil || nextPageInt == 0 {
break
}
page = nextPageInt
}
page = nextPageInt
}
c.logger.Debugf("🗑️ %d trashed assets found", len(allTrashedAssets))

Expand Down
Loading
Loading