From 0cf2dc3fffb0b8bd8fa3172df10dec6fe9584ee0 Mon Sep 17 00:00:00 2001 From: Major Date: Wed, 27 May 2026 19:46:01 +0200 Subject: [PATCH 1/5] add TExifInfo struct with FileSizeInByte field Add TExifInfo type to carry file size metadata from Immich API responses. The nullable pointer semantics ensure that assets without exif data fall through cleanly in downstream code rather than treating missing metadata as zero. Change-Type: feature Scope: types --- pkg/utils/types.go | 48 ++++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/pkg/utils/types.go b/pkg/utils/types.go index acc2b62..22ac980 100644 --- a/pkg/utils/types.go +++ b/pkg/utils/types.go @@ -52,30 +52,40 @@ type TRegex struct { PromoteKeys []string `json:"promote_keys,omitempty"` // Optional: ordered list of values for promotion (first = highest priority) } +/************************************************************************************************** +** TExifInfo carries the subset of Immich's ExifResponseDto we care about. +** Pointer-on-parent semantics: when Immich omits exifInfo entirely, the parent's pointer +** is nil and downstream code can fall through cleanly instead of treating "missing" as zero. +**************************************************************************************************/ +type TExifInfo struct { + FileSizeInByte int64 `json:"fileSizeInByte"` // Original file size in bytes +} + /************************************************************************************************** ** TAsset represents an Immich asset with all its metadata and properties. ** This structure matches the Immich API response format. **************************************************************************************************/ type TAsset struct { - ID string `json:"id"` // Unique identifier - DeviceAssetID string `json:"deviceAssetId"` // Original device asset ID - DeviceID string `json:"deviceId"` // Device identifier - OriginalFileName string `json:"originalFileName"` // Original file name - OriginalPath string `json:"originalPath"` // Original file path - LocalDateTime string `json:"localDateTime"` // Local capture time - FileCreatedAt string `json:"fileCreatedAt"` // File creation time - FileModifiedAt string `json:"fileModifiedAt"` // File modification time - HasMetadata bool `json:"hasMetadata"` // Whether asset has metadata - IsArchived bool `json:"isArchived"` // Whether asset is archived - IsFavorite bool `json:"isFavorite"` // Whether asset is favorited - IsOffline bool `json:"isOffline"` // Whether asset is offline - IsTrashed bool `json:"isTrashed"` // Whether asset is trashed - OwnerID string `json:"ownerId"` // Owner identifier - 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) - Stack *TStack `json:"stack,omitempty"` // Associated stack if any + ID string `json:"id"` // Unique identifier + DeviceAssetID string `json:"deviceAssetId"` // Original device asset ID + DeviceID string `json:"deviceId"` // Device identifier + OriginalFileName string `json:"originalFileName"` // Original file name + OriginalPath string `json:"originalPath"` // Original file path + LocalDateTime string `json:"localDateTime"` // Local capture time + FileCreatedAt string `json:"fileCreatedAt"` // File creation time + FileModifiedAt string `json:"fileModifiedAt"` // File modification time + HasMetadata bool `json:"hasMetadata"` // Whether asset has metadata + IsArchived bool `json:"isArchived"` // Whether asset is archived + IsFavorite bool `json:"isFavorite"` // Whether asset is favorited + IsOffline bool `json:"isOffline"` // Whether asset is offline + IsTrashed bool `json:"isTrashed"` // Whether asset is trashed + OwnerID string `json:"ownerId"` // Owner identifier + 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) + Stack *TStack `json:"stack,omitempty"` // Associated stack if any + ExifInfo *TExifInfo `json:"exifInfo,omitempty"` // Optional EXIF metadata (size, etc.) } /************************************************************************************************** From 0da0167c2e4171d11d34a09d0cb508f249949974 Mon Sep 17 00:00:00 2001 From: Major Date: Wed, 27 May 2026 19:46:07 +0200 Subject: [PATCH 2/5] implement file size-based parent selection Add biggestSize and smallestSize magic keywords for tie-breaking in parent selection. These keywords act as fallback buckets after extension preferences, allowing size-based promotion while respecting user-configured extension priorities (e.g., a 12MB JPG wins over a 28MB RAW when JPG is listed first in PARENT_EXT_PROMOTE). Introduce isMagicPromoteKeyword() to unify handling of all tie-breaker keywords (biggestNumber, biggestSize, smallestSize, sequence variants). Refactor getPromoteIndex and getPromoteIndexWithMode to use this helper. Scope: stacker Change-Type: feature --- pkg/stacker/stacker_promote.go | 78 ++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/pkg/stacker/stacker_promote.go b/pkg/stacker/stacker_promote.go index 5100958..a11d0f2 100644 --- a/pkg/stacker/stacker_promote.go +++ b/pkg/stacker/stacker_promote.go @@ -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: @@ -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) { @@ -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 } } @@ -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) @@ -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 } } @@ -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 } } @@ -465,7 +483,7 @@ func isSequencePattern(promoteList []string) bool { patternRegex := regexp.MustCompile(`^(.*?)(\d+)(.*?)$`) for _, item := range promoteList { - if item == "biggestNumber" { + if isMagicPromoteKeyword(item) { continue } @@ -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 @@ -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 @@ -739,6 +773,24 @@ 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. Requires positive sizes on both sides so assets with + // missing exif data fall through to the alphabetical sort below instead of being + // pinned at size=0. + hasBiggestSize := utils.Contains(promoteSubstrings, "biggestSize") + hasSmallestSize := utils.Contains(promoteSubstrings, "smallestSize") + if hasBiggestSize || hasSmallestSize { + iSize := assetSize(stack[i]) + jSize := assetSize(stack[j]) + if iSize > 0 && jSize > 0 && iSize != jSize { + if hasBiggestSize { + return iSize > jSize // largest first + } + return iSize < jSize // smallest first + } + } + return iOriginalFileNameNoExt < jOriginalFileNameNoExt }) From 10c7f216d42d3ec12b076911c1d47b5f2f7cfc10 Mon Sep 17 00:00:00 2001 From: Major Date: Wed, 27 May 2026 19:46:10 +0200 Subject: [PATCH 3/5] add comprehensive tests for size-based promotion Add 11 test cases covering the new size promotion feature: basic biggestSize/smallestSize behavior, interaction with substring matching, missing/partial exif data handling, the real-world DSLR scenario (JPG+RAW+exported JPG), equal sizes, and JSON unmarshaling from Immich API responses. Scope: test Change-Type: test --- pkg/stacker/stacker_size_promote_test.go | 191 +++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 pkg/stacker/stacker_size_promote_test.go diff --git a/pkg/stacker/stacker_size_promote_test.go b/pkg/stacker/stacker_size_promote_test.go new file mode 100644 index 0000000..04ea2ef --- /dev/null +++ b/pkg/stacker/stacker_size_promote_test.go @@ -0,0 +1,191 @@ +package stacker + +import ( + "encoding/json" + "testing" + + "github.com/majorfi/immich-stack/pkg/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/************************************************************************************************** +** sizedAssetFactory builds a TAsset with the given filename and size in bytes. Used here rather +** than assetFactory because size-promotion tests need to control ExifInfo.FileSizeInByte directly. +**************************************************************************************************/ +func sizedAssetFactory(filename string, sizeInByte int64) utils.TAsset { + return utils.TAsset{ + ID: filename, + OriginalFileName: filename, + ExifInfo: &utils.TExifInfo{FileSizeInByte: sizeInByte}, + } +} + +/************************************************************************************************** +** emptyPromoteData returns an empty thread-safe promote data store + empty promotion maps. +** Used to satisfy sortStack's signature when no regex-promotion criteria are exercised. +**************************************************************************************************/ +func emptyPromoteData() (*safePromoteData, map[int]map[string]int) { + return &safePromoteData{data: make(map[string]map[string]string)}, make(map[int]map[string]int) +} + +/************************************************************************************************** +** TestSortStack_BiggestSize covers the new biggestSize magic keyword: when present in the +** promote list, the asset with the largest exifInfo.fileSizeInByte wins the parent slot. +**************************************************************************************************/ +func TestSortStack_BiggestSize(t *testing.T) { + data, maps := emptyPromoteData() + assets := []utils.TAsset{ + sizedAssetFactory("IMG_1234.jpg", 2_500_000), // small original + sizedAssetFactory("IMG_1234_b.jpg", 8_400_000), // large exported + sizedAssetFactory("IMG_1234_a.jpg", 5_100_000), // medium + } + sorted := sortStack(assets, "biggestSize", "", []string{"_", "."}, + utils.DefaultCriteria, data, maps) + assert.Equal(t, "IMG_1234_b.jpg", sorted[0].OriginalFileName, + "largest file should be promoted as parent") + assert.Equal(t, "IMG_1234_a.jpg", sorted[1].OriginalFileName) + assert.Equal(t, "IMG_1234.jpg", sorted[2].OriginalFileName) +} + +/************************************************************************************************** +** TestSortStack_SmallestSize is the symmetric counterpart — useful for stacks where the +** thumbnail/reduced variant should win (less common but supported for symmetry). +**************************************************************************************************/ +func TestSortStack_SmallestSize(t *testing.T) { + data, maps := emptyPromoteData() + assets := []utils.TAsset{ + sizedAssetFactory("IMG_a.jpg", 8_000_000), + sizedAssetFactory("IMG_b.jpg", 1_200_000), + sizedAssetFactory("IMG_c.jpg", 4_500_000), + } + sorted := sortStack(assets, "smallestSize", "", []string{"_", "."}, + utils.DefaultCriteria, data, maps) + assert.Equal(t, "IMG_b.jpg", sorted[0].OriginalFileName, + "smallest file should be promoted as parent") +} + +/************************************************************************************************** +** TestSortStack_BiggestSize_WithSubstringMatch: substring promotes still take priority over the +** size tie-breaker. This is the realistic case where a user wants edits to win, with size only +** breaking ties within the matched (or unmatched) buckets. +**************************************************************************************************/ +func TestSortStack_BiggestSize_WithSubstringMatch(t *testing.T) { + data, maps := emptyPromoteData() + assets := []utils.TAsset{ + sizedAssetFactory("IMG_1234.jpg", 9_000_000), // unmatched, huge + sizedAssetFactory("IMG_1234_edited.jpg", 3_000_000), // matches "_edited", small + sizedAssetFactory("IMG_1234_edited2.jpg", 5_500_000), // matches "_edited", medium + } + sorted := sortStack(assets, "_edited,biggestSize", "", []string{"_", "."}, + utils.DefaultCriteria, data, maps) + // "_edited" match wins over the larger unmatched original, then size tie-breaks + // inside the matched bucket. + assert.Equal(t, "IMG_1234_edited2.jpg", sorted[0].OriginalFileName, + "largest _edited file should be parent") + assert.Equal(t, "IMG_1234_edited.jpg", sorted[1].OriginalFileName) + assert.Equal(t, "IMG_1234.jpg", sorted[2].OriginalFileName, + "unmatched original is last despite being largest") +} + +/************************************************************************************************** +** TestSortStack_BiggestSize_MissingExif verifies the "no exif" fall-through: when neither asset +** has exif data we must NOT pin them to size=0=0 (which would short-circuit the tie-break); +** the alphabetical fallback must still kick in. +**************************************************************************************************/ +func TestSortStack_BiggestSize_MissingExif(t *testing.T) { + data, maps := emptyPromoteData() + assets := []utils.TAsset{ + {ID: "b", OriginalFileName: "b.jpg"}, + {ID: "a", OriginalFileName: "a.jpg"}, + } + sorted := sortStack(assets, "biggestSize", "", []string{"_", "."}, + utils.DefaultCriteria, data, maps) + assert.Equal(t, "a.jpg", sorted[0].OriginalFileName, + "with no exif data, fallback alphabetical sort must still apply") +} + +/************************************************************************************************** +** TestSortStack_BiggestSize_PartialExif: when only one asset has exif we should not flip the +** alphabetical order just because the other defaults to 0. The size tie-break is skipped when +** EITHER asset is missing data, so the relative order falls through to the next sort rule. +**************************************************************************************************/ +func TestSortStack_BiggestSize_PartialExif(t *testing.T) { + data, maps := emptyPromoteData() + assets := []utils.TAsset{ + sizedAssetFactory("a.jpg", 5_000_000), + {ID: "b", OriginalFileName: "b.jpg"}, // no exif + } + sorted := sortStack(assets, "biggestSize", "", []string{"_", "."}, + utils.DefaultCriteria, data, maps) + // Without partial-exif protection, "a" (size=5MB) would beat "b" (size=0) and we'd + // promote a.jpg even though b.jpg has no comparable metadata. Alphabetical wins instead. + assert.Equal(t, "a.jpg", sorted[0].OriginalFileName, + "alphabetical resolves the tie when one side has no exif") +} + +/************************************************************************************************** +** TestSortStack_BiggestSize_DSLRScenario reproduces the exact use case from issue #47: an original +** JPG (camera output) + RAW/CR2 + a substantially larger exported JPG from Lightroom. The +** exported JPG should win as the stack parent. +**************************************************************************************************/ +func TestSortStack_BiggestSize_DSLRScenario(t *testing.T) { + data, maps := emptyPromoteData() + assets := []utils.TAsset{ + sizedAssetFactory("IMG_5821.JPG", 4_800_000), // camera JPG + sizedAssetFactory("IMG_5821.CR2", 28_000_000), // RAW + sizedAssetFactory("IMG_5821.jpg", 12_400_000), // exported JPG (the desired parent) + } + // PARENT_EXT_PROMOTE prefers .jpg over .cr2; biggestSize tie-breaks between the two .jpg files. + sorted := sortStack(assets, "biggestSize", ".jpg,.jpeg,.png", []string{"_", "."}, + utils.DefaultCriteria, data, maps) + assert.Equal(t, "IMG_5821.jpg", sorted[0].OriginalFileName, + "exported JPG (largest .jpg) should be the stack parent") +} + +/************************************************************************************************** +** TestSortStack_BiggestSize_EqualSizes: when sizes match exactly we should fall through to the +** next tie-breaker (extension rank / alphabetical). Guards against accidentally claiming the +** tie when the comparison is a no-op. +**************************************************************************************************/ +func TestSortStack_BiggestSize_EqualSizes(t *testing.T) { + data, maps := emptyPromoteData() + assets := []utils.TAsset{ + sizedAssetFactory("z.jpg", 5_000_000), + sizedAssetFactory("a.jpg", 5_000_000), + } + sorted := sortStack(assets, "biggestSize", "", []string{"_", "."}, + utils.DefaultCriteria, data, maps) + assert.Equal(t, "a.jpg", sorted[0].OriginalFileName, + "equal sizes fall through to alphabetical") +} + +/************************************************************************************************** +** TestExifInfo_JSONUnmarshal confirms the API plumbing: an Immich-shaped JSON payload with the +** exifInfo.fileSizeInByte field is correctly deserialized into TAsset.ExifInfo. Without this, +** the size tie-break above can never engage in production. +**************************************************************************************************/ +func TestExifInfo_JSONUnmarshal(t *testing.T) { + payload := []byte(`{ + "id": "abc", + "originalFileName": "IMG.jpg", + "exifInfo": { + "fileSizeInByte": 12345678 + } + }`) + var asset utils.TAsset + require.NoError(t, json.Unmarshal(payload, &asset)) + require.NotNil(t, asset.ExifInfo, "exifInfo should be deserialized") + assert.Equal(t, int64(12345678), asset.ExifInfo.FileSizeInByte) +} + +/************************************************************************************************** +** TestExifInfo_JSONUnmarshal_Missing covers Immich responses that omit exifInfo entirely. +** ExifInfo must stay nil (not be auto-initialized) so the size tie-breaker can skip cleanly. +**************************************************************************************************/ +func TestExifInfo_JSONUnmarshal_Missing(t *testing.T) { + payload := []byte(`{"id": "abc", "originalFileName": "IMG.jpg"}`) + var asset utils.TAsset + require.NoError(t, json.Unmarshal(payload, &asset)) + assert.Nil(t, asset.ExifInfo, "missing exifInfo must leave ExifInfo nil") +} From c210e002fdb3c865a0200224636a9484c6b039cb Mon Sep 17 00:00:00 2001 From: Major Date: Wed, 27 May 2026 19:46:13 +0200 Subject: [PATCH 4/5] document file size-based parent promotion Document the new biggestSize and smallestSize magic keywords for parent selection. Include configuration examples, the DSLR use case (exported JPG from Lightroom winning over camera originals), and technical details about evaluation order (size tie-break runs after extension preferences but before alphabetical sort). Change-Type: docs Scope: docs --- docs/api-reference/environment-variables.md | 8 ++-- docs/features/edited-photo-promotion.md | 46 +++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/docs/api-reference/environment-variables.md b/docs/api-reference/environment-variables.md index 4770a6f..29dfacf 100644 --- a/docs/api-reference/environment-variables.md +++ b/docs/api-reference/environment-variables.md @@ -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 diff --git a/docs/features/edited-photo-promotion.md b/docs/features/edited-photo-promotion.md index f6c03d2..2aa6805 100644 --- a/docs/features/edited-photo-promotion.md +++ b/docs/features/edited-photo-promotion.md @@ -127,6 +127,52 @@ 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 without exif data + fall through to the alphabetical sort — they are **not** treated 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: From 47d3720143ab5099ec3eaee3ffc0e947ad5e35e8 Mon Sep 17 00:00:00 2001 From: Major Date: Thu, 28 May 2026 09:35:32 +0200 Subject: [PATCH 5/5] fix(stacker): ensure transitive sorting in size-based parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original size-based comparator was non-transitive when some assets lacked exif data. With 3+ assets (e.g., z:10MB, a:5MB, m:none), pairwise comparisons created cycles: z→a (by size), a→m (alphabetical fallthrough), m→z (alphabetical fallthrough). This violated sort.SliceStable's total-order contract. Fix partitions assets into buckets using a per-asset predicate: assets with positive size go first (sorted by size direction), assets without exif go last. Bucket membership is determined by a single asset property, not the pair, ensuring transitivity. Added regression tests covering both biggestSize and smallestSize directions with mixed exif data. Change-Type: fix Scope: stacker --- docs/features/edited-photo-promotion.md | 6 +- pkg/stacker/stacker_promote.go | 24 +++++-- pkg/stacker/stacker_size_promote_test.go | 85 ++++++++++++++++++++++-- 3 files changed, 99 insertions(+), 16 deletions(-) diff --git a/docs/features/edited-photo-promotion.md b/docs/features/edited-photo-promotion.md index 2aa6805..0ebdc11 100644 --- a/docs/features/edited-photo-promotion.md +++ b/docs/features/edited-photo-promotion.md @@ -167,8 +167,10 @@ With PARENT_FILENAME_PROMOTE=biggestSize and PARENT_EXT_PROMOTE=.jpg,.jpeg,.png: - `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 without exif data - fall through to the alphabetical sort — they are **not** treated as size zero. +- 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). diff --git a/pkg/stacker/stacker_promote.go b/pkg/stacker/stacker_promote.go index a11d0f2..fd501a6 100644 --- a/pkg/stacker/stacker_promote.go +++ b/pkg/stacker/stacker_promote.go @@ -723,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) @@ -775,15 +779,21 @@ func sortStack(stack []utils.TAsset, parentFilenamePromote string, parentExtProm // 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. Requires positive sizes on both sides so assets with - // missing exif data fall through to the alphabetical sort below instead of being - // pinned at size=0. - hasBiggestSize := utils.Contains(promoteSubstrings, "biggestSize") - hasSmallestSize := utils.Contains(promoteSubstrings, "smallestSize") - if hasBiggestSize || hasSmallestSize { + // 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]) - if iSize > 0 && jSize > 0 && iSize != jSize { + 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 } diff --git a/pkg/stacker/stacker_size_promote_test.go b/pkg/stacker/stacker_size_promote_test.go index 04ea2ef..d8ab130 100644 --- a/pkg/stacker/stacker_size_promote_test.go +++ b/pkg/stacker/stacker_size_promote_test.go @@ -106,22 +106,93 @@ func TestSortStack_BiggestSize_MissingExif(t *testing.T) { } /************************************************************************************************** -** TestSortStack_BiggestSize_PartialExif: when only one asset has exif we should not flip the -** alphabetical order just because the other defaults to 0. The size tie-break is skipped when -** EITHER asset is missing data, so the relative order falls through to the next sort rule. +** TestSortStack_BiggestSize_PartialExif: assets with a positive size always rank ahead of +** assets without exif data. The "has size" predicate is per-asset, not per-pair, which keeps +** the comparator transitive even though the alphabetical fall-through is non-monotonic with +** respect to size. **************************************************************************************************/ func TestSortStack_BiggestSize_PartialExif(t *testing.T) { data, maps := emptyPromoteData() assets := []utils.TAsset{ + {ID: "z", OriginalFileName: "z.jpg"}, // no exif sizedAssetFactory("a.jpg", 5_000_000), - {ID: "b", OriginalFileName: "b.jpg"}, // no exif } sorted := sortStack(assets, "biggestSize", "", []string{"_", "."}, utils.DefaultCriteria, data, maps) - // Without partial-exif protection, "a" (size=5MB) would beat "b" (size=0) and we'd - // promote a.jpg even though b.jpg has no comparable metadata. Alphabetical wins instead. assert.Equal(t, "a.jpg", sorted[0].OriginalFileName, - "alphabetical resolves the tie when one side has no exif") + "asset with exif data ranks ahead of asset without, even if alphabetically later") + assert.Equal(t, "z.jpg", sorted[1].OriginalFileName, + "missing-exif asset goes to the back bucket") +} + +/************************************************************************************************** +** TestSortStack_BiggestSize_TransitivityWithMissingExif is the regression test for the bug +** Copilot caught on PR #64. The original implementation applied the size tie-break pairwise +** (only when both sides had positive size, falling through to alphabetical otherwise), which +** produced a non-transitive comparator with 3+ assets: +** +** - z.jpg (10MB) vs a.jpg (5MB) → both have size → z first by size +** - z.jpg (10MB) vs m.jpg (none) → alphabetical fall-through → m first +** - a.jpg (5MB) vs m.jpg (none) → alphabetical fall-through → a first +** +** => z