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
8 changes: 4 additions & 4 deletions docs/api-reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ Note:

## Parent Selection

| Variable | Description | Default | Example |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------- |
| `PARENT_FILENAME_PROMOTE` | Substrings to promote as parent filenames. Supports empty string for negative matching, the `sequence` keyword and automatic sequence detection for burst photos. | `cover,edit,crop,hdr,biggestNumber` | `,_edited` or `edit,raw` or `COVER,sequence` or `0000,0001,0002,0003` |
| `PARENT_EXT_PROMOTE` | Extensions to promote as parent files | `.jpg,.png,.jpeg,.heic,.dng` | `.jpg,.dng` |
| Variable | Description | Default | Example |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- |
| `PARENT_FILENAME_PROMOTE` | Substrings to promote as parent filenames. Supports empty string for negative matching, the `sequence` keyword, automatic sequence detection for burst photos, and the `biggestNumber` / `biggestSize` / `smallestSize` magic keywords for tie-breakers. | `cover,edit,crop,hdr,biggestNumber` | `,_edited` or `edit,raw` or `COVER,sequence` or `0000,0001,0002,0003` or `_edited,biggestSize` |
| `PARENT_EXT_PROMOTE` | Extensions to promote as parent files | `.jpg,.png,.jpeg,.heic,.dng` | `.jpg,.dng` |

### Empty String for Negative Matching

Expand Down
48 changes: 48 additions & 0 deletions docs/features/edited-photo-promotion.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,54 @@ Simply remove `biggestNumber` from your promote list:
PARENT_FILENAME_PROMOTE=edit,crop,hdr
```

## Promote by File Size (`biggestSize` / `smallestSize`)

Sometimes the filename alone can't tell you which version of a photo was the post-processed
export. A typical example: shooting JPG+RAW on a DSLR and exporting an edited JPG from
Lightroom — all three end up sharing the same base filename but the exported JPG is
substantially larger than the camera's original. Use the `biggestSize` (or, symmetrically,
`smallestSize`) magic keyword in `PARENT_FILENAME_PROMOTE` to break ties on file size.

### Configuration

```bash
# Always promote the largest file when other criteria don't resolve
PARENT_FILENAME_PROMOTE=biggestSize

# Combine with substring matches: pick "_edited" files first, then largest within that bucket
PARENT_FILENAME_PROMOTE=_edited,biggestSize

# Symmetric: promote the smallest file (useful for thumbnails / reduced exports)
PARENT_FILENAME_PROMOTE=smallestSize
```

### Example: DSLR with JPG + RAW + Lightroom Export

```
Files: Size
- IMG_5821.JPG (camera JPG) 4.8 MB
- IMG_5821.CR2 (camera RAW) 28.0 MB
- IMG_5821.jpg (exported from Lightroom) 12.4 MB

With PARENT_FILENAME_PROMOTE=biggestSize and PARENT_EXT_PROMOTE=.jpg,.jpeg,.png:
1. IMG_5821.jpg ← winner (largest among the JPGs)
2. IMG_5821.JPG
3. IMG_5821.CR2 (lower extension priority)
```

### How It Works

- `biggestSize` and `smallestSize` are evaluated **after** extension preferences, so a
high-priority extension still wins over an unrelated larger file (e.g. a 28 MB RAW
doesn't outrank a 12 MB JPG when `.jpg` is listed first in `PARENT_EXT_PROMOTE`).
- The tie-break uses `exifInfo.fileSizeInByte` from Immich. Assets with a positive size
are sorted first (by size, in the chosen direction); assets **without** exif data are
bucketed at the back and never win as parent. Missing exif is treated as "no data",
not as size zero.
- If both `biggestSize` and `smallestSize` are present, `biggestSize` wins.
- The keywords compose with `biggestNumber` and substring matchers; ordering in the
list does not affect their precedence (it's hard-coded by sort layer).

## Technical Details

The `biggestNumber` feature:
Expand Down
88 changes: 75 additions & 13 deletions pkg/stacker/stacker_promote.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ func isSequenceKeyword(promote string) bool {
return promote == "sequence" || strings.HasPrefix(promote, "sequence:")
}

/**************************************************************************************************
** isMagicPromoteKeyword reports whether a promote-list entry is a tie-breaker keyword that should
** never be treated as a filename substring. These keywords influence ordering through a side path
** (numeric suffix, file size, etc.) rather than substring matching.
**************************************************************************************************/
func isMagicPromoteKeyword(promote string) bool {
switch promote {
case "biggestNumber", "biggestSize", "smallestSize":
return true
}
return isSequenceKeyword(promote)
}

/**************************************************************************************************
** extractSequencePattern extracts the pattern from a sequence keyword.
** Examples:
Expand Down Expand Up @@ -80,7 +93,7 @@ func getPromoteIndex(value string, promoteList []string) int {
if emptyStringIndex == -1 {
emptyStringIndex = idx
}
} else if promote != "biggestNumber" {
} else if !isMagicPromoteKeyword(promote) {
hasNonEmptyStrings = true
loweredPromote := strings.ToLower(promote)
if strings.Contains(loweredValue, loweredPromote) {
Expand All @@ -100,9 +113,10 @@ func getPromoteIndex(value string, promoteList []string) int {
return emptyStringIndex
}

// If 'biggestNumber' is in the promote list, assign its index to unmatched files
// Magic keywords (biggestNumber, biggestSize, smallestSize) at the end of the list act as a
// "no match" fallback bucket so the sort routine can still apply a tie-breaker afterwards.
for idx, promote := range promoteList {
if promote == "biggestNumber" {
if isMagicPromoteKeyword(promote) {
return idx
}
}
Expand Down Expand Up @@ -168,7 +182,7 @@ func getPromoteIndexWithMode(value string, promoteList []string, matchMode strin
if emptyStringIndex == -1 {
emptyStringIndex = idx // Only record the first empty string
}
} else if !isSequenceKeyword(promote) {
} else if !isMagicPromoteKeyword(promote) {
hasNonEmptyStrings = true
// Check for match while we're iterating
loweredPromote := strings.ToLower(promote)
Expand Down Expand Up @@ -312,9 +326,13 @@ func getPromoteIndexWithMode(value string, promoteList []string, matchMode strin
}
}

// If 'biggestNumber' is in the promote list, assign its index to unmatched files
// Size/number tie-breaker keywords act as a fallback bucket for unmatched files so the
// sort routine can still apply a tie-breaker. Sequence keywords are intentionally NOT
// included here — they have their own resolution path above and falling through to
// len(promoteList) is the documented behavior when a sequence pattern is absent.
for idx, promote := range promoteList {
if promote == "biggestNumber" {
switch promote {
case "biggestNumber", "biggestSize", "smallestSize":
return idx
}
}
Expand Down Expand Up @@ -414,7 +432,7 @@ func detectPromoteMatchMode(promoteList []string, sampleFilename string) string
for _, promote := range promoteList {
if isSequenceKeyword(promote) {
hasSequenceKeyword = true
} else if promote != "" && promote != "biggestNumber" {
} else if promote != "" && !isMagicPromoteKeyword(promote) {
hasNonSequenceItems = true
}
}
Expand Down Expand Up @@ -465,7 +483,7 @@ func isSequencePattern(promoteList []string) bool {
patternRegex := regexp.MustCompile(`^(.*?)(\d+)(.*?)$`)

for _, item := range promoteList {
if item == "biggestNumber" {
if isMagicPromoteKeyword(item) {
continue
}

Expand Down Expand Up @@ -612,6 +630,17 @@ func buildCriteriaIdentifier(key string, index int) string {
return fmt.Sprintf("%s:%d", key, index)
}

/**************************************************************************************************
** assetSize returns the file size in bytes for an asset, or 0 when Immich didn't return exif info.
** Used by the biggestSize / smallestSize promote keywords as a sort tie-breaker.
**************************************************************************************************/
func assetSize(a utils.TAsset) int64 {
if a.ExifInfo == nil {
return 0
}
return a.ExifInfo.FileSizeInByte
}

/**************************************************************************************************
** extractLargestNumberSuffix finds a numeric suffix at the end of the base filename (before the
** extension), but ONLY if it appears after a delimiter. If no delimiters are present, always
Expand Down Expand Up @@ -657,11 +686,16 @@ func extractLargestNumberSuffix(filename string, delimiters []string) int {
/**************************************************************************************************
** sortStack sorts a stack of assets based on filename and extension priority.
** The order is:
** 1. Regex-based promotion (if criteria has regex with promote_index)
** 2. Promoted filenames (PARENT_FILENAME_PROMOTE, comma-separated, order matters)
** 3. Promoted extensions (PARENT_EXT_PROMOTE, comma-separated, order matters)
** 4. Extension priority (jpeg > jpg > png > others)
** 5. Alphabetical order (case-sensitive)
** 1. Regex-based promotion (if criteria has regex with promote_index)
** 2. Promoted filenames (PARENT_FILENAME_PROMOTE, comma-separated, order matters)
** 3. 'biggestNumber' tie-break within the same PARENT_FILENAME_PROMOTE bucket — runs early
** because it's filename-suffix based (e.g. picks photo~3.jpg over photo~2.jpg)
** 4. Promoted extensions (PARENT_EXT_PROMOTE, comma-separated, order matters)
** 5. Extension priority (jpeg > jpg > png > others)
** 6. 'biggestSize' / 'smallestSize' tie-break — runs late because it's metadata-based,
** so configured extension preferences still win (e.g. a small .jpg beats a huge .cr2
** when .jpg is first in PARENT_EXT_PROMOTE)
** 7. Alphabetical order (case-sensitive)
**
** @param stack - List of assets to sort
** @param parentFilenamePromote - Comma-separated list of filename substrings to promote
Expand Down Expand Up @@ -689,6 +723,10 @@ func sortStack(stack []utils.TAsset, parentFilenamePromote string, parentExtProm
matchMode = detectPromoteMatchMode(promoteSubstrings, stack[0].OriginalFileName)
}

hasBiggestSize := utils.Contains(promoteSubstrings, "biggestSize")
hasSmallestSize := utils.Contains(promoteSubstrings, "smallestSize")
sizeSortActive := hasBiggestSize || hasSmallestSize

sort.SliceStable(stack, func(i, j int) bool {
// First, check regex-based promotion
iRegexPromoteIdx := getRegexPromoteIndex(stack[i].ID, promoteData, stackCriteria, promotionMaps)
Expand Down Expand Up @@ -739,6 +777,30 @@ func sortStack(stack []utils.TAsset, parentFilenamePromote string, parentExtProm
return rankI > rankJ
}

// Metadata-based tie-break: 'biggestSize' / 'smallestSize'. Placed after extension
// preferences so e.g. a 28MB .cr2 doesn't beat a 12MB .jpg when the user listed .jpg
// first in PARENT_EXT_PROMOTE.
//
// Bucket partition for transitivity: assets WITH a positive size form the front
// bucket (sorted by size), assets WITHOUT exif data form the back bucket (fall
// through to alphabetical). The "has size" predicate is a property of a single
// asset, not of the pair, so the comparator is transitive.
if sizeSortActive {
iSize := assetSize(stack[i])
jSize := assetSize(stack[j])
iHasSize := iSize > 0
jHasSize := jSize > 0
if iHasSize != jHasSize {
return iHasSize // assets with exif data come before those without
}
if iHasSize && iSize != jSize {
if hasBiggestSize {
return iSize > jSize // largest first
}
return iSize < jSize // smallest first
}
}

return iOriginalFileNameNoExt < jOriginalFileNameNoExt
})

Expand Down
Loading
Loading