From e0b8d96ec8cfdfa20b7443c858d7278ea3ff31df Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 25 Jan 2026 15:47:16 +0100 Subject: [PATCH 1/9] Add shuffle sorting options and implement related functionality - Introduced shuffle sorting options in the layout and UI components. - Updated the CollectionView to utilize query parameters for sorting. - Enhanced the DisplaySettings to allow users to select shuffle intervals. - Modified backend logic to support shuffle sorting in scene loading and database queries. - Added tests for shuffle functionality and consistency. --- defaults.yaml | 1 + internal/collection/collection.go | 1 + internal/image/database.go | 55 ++++- internal/layout/common.go | 45 +++- internal/layout/common_test.go | 291 ++++++++++++++++++++++++++ internal/render/scene.go | 43 ++++ internal/render/scene_test.go | 201 ++++++++++++++++++ internal/scene/sceneSource.go | 33 ++- main.go | 23 +- ui/src/components/CollectionView.vue | 5 + ui/src/components/DisplaySettings.vue | 32 ++- 11 files changed, 700 insertions(+), 30 deletions(-) create mode 100644 internal/layout/common_test.go create mode 100644 internal/render/scene_test.go diff --git a/defaults.yaml b/defaults.yaml index bd1d6342..a14d78f7 100644 --- a/defaults.yaml +++ b/defaults.yaml @@ -7,6 +7,7 @@ collections: # - name: Collection Name # layout: album | timeline | wall + # sort: +date | -date | +shuffle-hourly | +shuffle-daily | +shuffle-weekly | +shuffle-monthly # limit: integer number of photos to limit to (for testing large collections) # expand_subdirs: true | false (expand subdirs of `dirs` to collections) # expand_sort: asc | desc (order of expanded subdirs) diff --git a/internal/collection/collection.go b/internal/collection/collection.go index a29dd1cc..3bbd0c78 100644 --- a/internal/collection/collection.go +++ b/internal/collection/collection.go @@ -17,6 +17,7 @@ type Collection struct { Id string `json:"id"` Name string `json:"name"` Layout string `json:"layout"` + Sort string `json:"sort"` Limit int `json:"limit"` IndexLimit int `json:"index_limit"` ExpandSubdirs bool `json:"expand_subdirs"` diff --git a/internal/image/database.go b/internal/image/database.go index 701bb9c3..b8b84e93 100644 --- a/internal/image/database.go +++ b/internal/image/database.go @@ -40,18 +40,23 @@ func toUnixMs(t time.Time) int64 { type ListOrder int32 const ( - None ListOrder = iota - DateAsc ListOrder = iota - DateDesc ListOrder = iota + None ListOrder = iota + DateAsc ListOrder = iota + DateDesc ListOrder = iota + ShuffleHourly ListOrder = iota + ShuffleDaily ListOrder = iota + ShuffleWeekly ListOrder = iota + ShuffleMonthly ListOrder = iota ) type ListOptions struct { - OrderBy ListOrder - Limit int - Expression search.Expression - Embedding clip.Embedding - Extensions []string - Batch int + OrderBy ListOrder + ShuffleSeed int64 + Limit int + Expression search.Expression + Embedding clip.Embedding + Extensions []string + Batch int } type DirsFunc func(dirs []string) @@ -1751,6 +1756,16 @@ func mergeSortedChannels(channels []<-chan SourcedInfo, order ListOrder, out cha q = (*DateAscQueue)(&s) case DateDesc: q = (*DateDescQueue)(&s) + case ShuffleHourly, ShuffleDaily, ShuffleWeekly, ShuffleMonthly: + // Shuffle is already done at SQL level, no need for sorted merge + // Just concatenate the channels + for _, ch := range channels { + for info := range ch { + out <- info + } + } + close(out) + return nil default: return fmt.Errorf("unsupported listing order") } @@ -1930,6 +1945,10 @@ func (source *Database) listWithPrefixIds(prefixIds []int64, options ListOptions sql += ` ORDER BY created_at_unix DESC ` + case ShuffleHourly, ShuffleDaily, ShuffleWeekly, ShuffleMonthly: + sql += ` + ORDER BY ((6364136223846793005 * (id + ?)) + 1442695040888963407) & 9223372036854775807 + ` default: panic("Unsupported listing order") } @@ -1976,6 +1995,13 @@ func (source *Database) listWithPrefixIds(prefixIds []int64, options ListOptions bindIndex++ } + // Bind shuffle seed if needed + switch options.OrderBy { + case ShuffleHourly, ShuffleDaily, ShuffleWeekly, ShuffleMonthly: + stmt.BindInt64(bindIndex, options.ShuffleSeed) + bindIndex++ + } + if options.Limit > 0 { stmt.BindInt64(bindIndex, (int64)(options.Limit)) } @@ -2152,6 +2178,10 @@ func (source *Database) ListWithEmbeddings(dirs []string, options ListOptions) < sql += ` ORDER BY created_at_unix DESC ` + case ShuffleHourly, ShuffleDaily, ShuffleWeekly, ShuffleMonthly: + sql += ` + ORDER BY ((6364136223846793005 * (id + ?)) + 1442695040888963407) & 9223372036854775807 + ` default: panic("Unsupported listing order") } @@ -2174,6 +2204,13 @@ func (source *Database) ListWithEmbeddings(dirs []string, options ListOptions) < bindIndex++ } + // Bind shuffle seed if needed + switch options.OrderBy { + case ShuffleHourly, ShuffleDaily, ShuffleWeekly, ShuffleMonthly: + stmt.BindInt64(bindIndex, options.ShuffleSeed) + bindIndex++ + } + if options.Limit > 0 { stmt.BindInt64(bindIndex, (int64)(options.Limit)) } diff --git a/internal/layout/common.go b/internal/layout/common.go index da9467f2..7a8b668f 100644 --- a/internal/layout/common.go +++ b/internal/layout/common.go @@ -31,9 +31,13 @@ const ( type Order int const ( - None Order = iota - DateAsc Order = iota - DateDesc Order = iota + None Order = iota + DateAsc Order = iota + DateDesc Order = iota + ShuffleHourly Order = iota + ShuffleDaily Order = iota + ShuffleWeekly Order = iota + ShuffleMonthly Order = iota ) func OrderFromSort(s string) Order { @@ -42,11 +46,46 @@ func OrderFromSort(s string) Order { return DateAsc case "-date": return DateDesc + case "+shuffle-hourly": + return ShuffleHourly + case "+shuffle-daily": + return ShuffleDaily + case "+shuffle-weekly": + return ShuffleWeekly + case "+shuffle-monthly": + return ShuffleMonthly default: return None } } +// ComputeShuffleSeed computes a deterministic seed based on the order type and given time +func ComputeShuffleSeed(order Order, t time.Time) int64 { + switch order { + case ShuffleHourly: + return t.Truncate(time.Hour).Unix() + case ShuffleDaily: + return t.Truncate(24 * time.Hour).Unix() + case ShuffleWeekly: + // Truncate to Monday + weekday := t.Weekday() + // Sunday is 0, Monday is 1, so we need to adjust + daysFromMonday := int(weekday) - 1 + if daysFromMonday < 0 { + daysFromMonday = 6 // Sunday + } + monday := t.AddDate(0, 0, -daysFromMonday) + return monday.Truncate(24 * time.Hour).Unix() + case ShuffleMonthly: + y, m, _ := t.Date() + loc := t.Location() + firstOfMonth := time.Date(y, m, 1, 0, 0, 0, 0, loc) + return firstOfMonth.Unix() + default: + return 0 + } +} + type Layout struct { Type Type `json:"type"` Order Order `json:"order"` diff --git a/internal/layout/common_test.go b/internal/layout/common_test.go new file mode 100644 index 00000000..6f505083 --- /dev/null +++ b/internal/layout/common_test.go @@ -0,0 +1,291 @@ +package layout + +import ( + "photofield/internal/render" + "testing" + "time" +) + +// TestShuffleConstantsMatchRender verifies that layout.Order shuffle constants +// match the corresponding render package constants +func TestShuffleConstantsMatchRender(t *testing.T) { + tests := []struct { + name string + layoutConst Order + renderConst int + }{ + {"ShuffleHourly", ShuffleHourly, render.ShuffleHourly}, + {"ShuffleDaily", ShuffleDaily, render.ShuffleDaily}, + {"ShuffleWeekly", ShuffleWeekly, render.ShuffleWeekly}, + {"ShuffleMonthly", ShuffleMonthly, render.ShuffleMonthly}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if int(tt.layoutConst) != tt.renderConst { + t.Errorf("%s mismatch: layout=%d, render=%d", + tt.name, int(tt.layoutConst), tt.renderConst) + } + }) + } +} + +func TestComputeShuffleSeed(t *testing.T) { + // Use a fixed location for consistent test results + loc := time.UTC + + tests := []struct { + name string + order Order + time time.Time + expected int64 + }{ + // Hourly tests + { + name: "hourly - middle of hour", + order: ShuffleHourly, + time: time.Date(2024, 6, 15, 14, 30, 45, 0, loc), + expected: time.Date(2024, 6, 15, 14, 0, 0, 0, loc).Unix(), + }, + { + name: "hourly - start of hour", + order: ShuffleHourly, + time: time.Date(2024, 6, 15, 14, 0, 0, 0, loc), + expected: time.Date(2024, 6, 15, 14, 0, 0, 0, loc).Unix(), + }, + { + name: "hourly - end of hour", + order: ShuffleHourly, + time: time.Date(2024, 6, 15, 14, 59, 59, 0, loc), + expected: time.Date(2024, 6, 15, 14, 0, 0, 0, loc).Unix(), + }, + + // Daily tests + { + name: "daily - middle of day", + order: ShuffleDaily, + time: time.Date(2024, 6, 15, 14, 30, 45, 0, loc), + expected: time.Date(2024, 6, 15, 0, 0, 0, 0, loc).Unix(), + }, + { + name: "daily - start of day", + order: ShuffleDaily, + time: time.Date(2024, 6, 15, 0, 0, 0, 0, loc), + expected: time.Date(2024, 6, 15, 0, 0, 0, 0, loc).Unix(), + }, + { + name: "daily - end of day", + order: ShuffleDaily, + time: time.Date(2024, 6, 15, 23, 59, 59, 0, loc), + expected: time.Date(2024, 6, 15, 0, 0, 0, 0, loc).Unix(), + }, + + // Weekly tests - Monday is the start of week + { + name: "weekly - Monday", + order: ShuffleWeekly, + time: time.Date(2024, 6, 17, 14, 30, 0, 0, loc), // Monday + expected: time.Date(2024, 6, 17, 0, 0, 0, 0, loc).Unix(), + }, + { + name: "weekly - Tuesday", + order: ShuffleWeekly, + time: time.Date(2024, 6, 18, 14, 30, 0, 0, loc), // Tuesday + expected: time.Date(2024, 6, 17, 0, 0, 0, 0, loc).Unix(), // Previous Monday + }, + { + name: "weekly - Sunday", + order: ShuffleWeekly, + time: time.Date(2024, 6, 16, 14, 30, 0, 0, loc), // Sunday + expected: time.Date(2024, 6, 10, 0, 0, 0, 0, loc).Unix(), // Previous Monday + }, + { + name: "weekly - Saturday", + order: ShuffleWeekly, + time: time.Date(2024, 6, 22, 14, 30, 0, 0, loc), // Saturday + expected: time.Date(2024, 6, 17, 0, 0, 0, 0, loc).Unix(), // Previous Monday + }, + + // Monthly tests + { + name: "monthly - middle of month", + order: ShuffleMonthly, + time: time.Date(2024, 6, 15, 14, 30, 45, 0, loc), + expected: time.Date(2024, 6, 1, 0, 0, 0, 0, loc).Unix(), + }, + { + name: "monthly - first of month", + order: ShuffleMonthly, + time: time.Date(2024, 6, 1, 0, 0, 0, 0, loc), + expected: time.Date(2024, 6, 1, 0, 0, 0, 0, loc).Unix(), + }, + { + name: "monthly - last day of month", + order: ShuffleMonthly, + time: time.Date(2024, 6, 30, 23, 59, 59, 0, loc), + expected: time.Date(2024, 6, 1, 0, 0, 0, 0, loc).Unix(), + }, + { + name: "monthly - February (leap year)", + order: ShuffleMonthly, + time: time.Date(2024, 2, 29, 12, 0, 0, 0, loc), + expected: time.Date(2024, 2, 1, 0, 0, 0, 0, loc).Unix(), + }, + + // None/invalid order + { + name: "none order", + order: None, + time: time.Date(2024, 6, 15, 14, 30, 45, 0, loc), + expected: 0, + }, + { + name: "date asc order", + order: DateAsc, + time: time.Date(2024, 6, 15, 14, 30, 45, 0, loc), + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ComputeShuffleSeed(tt.order, tt.time) + if result != tt.expected { + t.Errorf("ComputeShuffleSeed(%v, %v) = %d; want %d", + tt.order, tt.time, result, tt.expected) + } + }) + } +} + +func TestComputeShuffleSeed_Consistency(t *testing.T) { + loc := time.UTC + + t.Run("same hour produces same seed", func(t *testing.T) { + time1 := time.Date(2024, 6, 15, 14, 10, 0, 0, loc) + time2 := time.Date(2024, 6, 15, 14, 50, 0, 0, loc) + + seed1 := ComputeShuffleSeed(ShuffleHourly, time1) + seed2 := ComputeShuffleSeed(ShuffleHourly, time2) + + if seed1 != seed2 { + t.Errorf("Expected same seed for same hour, got %d and %d", seed1, seed2) + } + }) + + t.Run("different hours produce different seeds", func(t *testing.T) { + time1 := time.Date(2024, 6, 15, 14, 30, 0, 0, loc) + time2 := time.Date(2024, 6, 15, 15, 30, 0, 0, loc) + + seed1 := ComputeShuffleSeed(ShuffleHourly, time1) + seed2 := ComputeShuffleSeed(ShuffleHourly, time2) + + if seed1 == seed2 { + t.Errorf("Expected different seeds for different hours, both got %d", seed1) + } + }) + + t.Run("same day produces same seed", func(t *testing.T) { + time1 := time.Date(2024, 6, 15, 8, 0, 0, 0, loc) + time2 := time.Date(2024, 6, 15, 20, 0, 0, 0, loc) + + seed1 := ComputeShuffleSeed(ShuffleDaily, time1) + seed2 := ComputeShuffleSeed(ShuffleDaily, time2) + + if seed1 != seed2 { + t.Errorf("Expected same seed for same day, got %d and %d", seed1, seed2) + } + }) + + t.Run("different days produce different seeds", func(t *testing.T) { + time1 := time.Date(2024, 6, 15, 12, 0, 0, 0, loc) + time2 := time.Date(2024, 6, 16, 12, 0, 0, 0, loc) + + seed1 := ComputeShuffleSeed(ShuffleDaily, time1) + seed2 := ComputeShuffleSeed(ShuffleDaily, time2) + + if seed1 == seed2 { + t.Errorf("Expected different seeds for different days, both got %d", seed1) + } + }) + + t.Run("same week produces same seed", func(t *testing.T) { + // Monday and Friday of same week + time1 := time.Date(2024, 6, 17, 10, 0, 0, 0, loc) // Monday + time2 := time.Date(2024, 6, 21, 15, 0, 0, 0, loc) // Friday + + seed1 := ComputeShuffleSeed(ShuffleWeekly, time1) + seed2 := ComputeShuffleSeed(ShuffleWeekly, time2) + + if seed1 != seed2 { + t.Errorf("Expected same seed for same week, got %d and %d", seed1, seed2) + } + }) + + t.Run("different weeks produce different seeds", func(t *testing.T) { + time1 := time.Date(2024, 6, 17, 12, 0, 0, 0, loc) // Monday week 1 + time2 := time.Date(2024, 6, 24, 12, 0, 0, 0, loc) // Monday week 2 + + seed1 := ComputeShuffleSeed(ShuffleWeekly, time1) + seed2 := ComputeShuffleSeed(ShuffleWeekly, time2) + + if seed1 == seed2 { + t.Errorf("Expected different seeds for different weeks, both got %d", seed1) + } + }) + + t.Run("same month produces same seed", func(t *testing.T) { + time1 := time.Date(2024, 6, 1, 0, 0, 0, 0, loc) + time2 := time.Date(2024, 6, 30, 23, 59, 59, 0, loc) + + seed1 := ComputeShuffleSeed(ShuffleMonthly, time1) + seed2 := ComputeShuffleSeed(ShuffleMonthly, time2) + + if seed1 != seed2 { + t.Errorf("Expected same seed for same month, got %d and %d", seed1, seed2) + } + }) + + t.Run("different months produce different seeds", func(t *testing.T) { + time1 := time.Date(2024, 6, 15, 12, 0, 0, 0, loc) + time2 := time.Date(2024, 7, 15, 12, 0, 0, 0, loc) + + seed1 := ComputeShuffleSeed(ShuffleMonthly, time1) + seed2 := ComputeShuffleSeed(ShuffleMonthly, time2) + + if seed1 == seed2 { + t.Errorf("Expected different seeds for different months, both got %d", seed1) + } + }) +} + +func TestComputeShuffleSeed_WeekBoundaries(t *testing.T) { + loc := time.UTC + + // Test that Sunday rolls back to previous Monday + sunday := time.Date(2024, 6, 23, 12, 0, 0, 0, loc) // Sunday + monday := time.Date(2024, 6, 17, 0, 0, 0, 0, loc) // Expected Monday (6 days earlier) + + result := ComputeShuffleSeed(ShuffleWeekly, sunday) + expected := monday.Unix() + + if result != expected { + resultTime := time.Unix(result, 0).UTC() + t.Errorf("Sunday should roll back to previous Monday.\nGot: %v (%d)\nExpected: %v (%d)", + resultTime, result, monday, expected) + } + + // Test all days of a week map to the same Monday + weekStart := time.Date(2024, 6, 17, 0, 0, 0, 0, loc) // Monday + expectedSeed := weekStart.Unix() + + days := []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"} + for i := 0; i < 7; i++ { + day := weekStart.AddDate(0, 0, i) + seed := ComputeShuffleSeed(ShuffleWeekly, day) + if seed != expectedSeed { + t.Errorf("%s (%v) produced seed %d, expected %d", + days[i], day, seed, expectedSeed) + } + } +} diff --git a/internal/render/scene.go b/internal/render/scene.go index 3dddd6fb..86574463 100644 --- a/internal/render/scene.go +++ b/internal/render/scene.go @@ -95,6 +95,49 @@ type Dependency interface { UpdatedAt() time.Time } +// Shuffle order constants matching layout.Order +const ( + ShuffleHourly = 3 + ShuffleDaily = 4 + ShuffleWeekly = 5 + ShuffleMonthly = 6 +) + +type ShuffleDependency struct { + Order int +} + +func (d *ShuffleDependency) UpdatedAt() time.Time { + // Return truncated current time based on order type + // When time crosses into a new period, this will be after scene creation + now := time.Now() + switch d.Order { + case ShuffleHourly: + return now.Truncate(time.Hour) + case ShuffleDaily: + y, m, day := now.Date() + loc := now.Location() + return time.Date(y, m, day, 0, 0, 0, 0, loc) + case ShuffleWeekly: + // Truncate to Monday at midnight local time + y, m, day := now.Date() + loc := now.Location() + weekday := now.Weekday() + daysFromMonday := int(weekday) - 1 + if daysFromMonday < 0 { + daysFromMonday = 6 // Sunday + } + mondayDate := time.Date(y, m, day-daysFromMonday, 0, 0, 0, 0, loc) + return mondayDate + case ShuffleMonthly: + y, m, _ := now.Date() + loc := now.Location() + return time.Date(y, m, 1, 0, 0, 0, 0, loc) + default: + return time.Time{} + } +} + type Scene struct { Id SceneId `json:"id"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/render/scene_test.go b/internal/render/scene_test.go new file mode 100644 index 00000000..92ef05a2 --- /dev/null +++ b/internal/render/scene_test.go @@ -0,0 +1,201 @@ +package render + +import ( + "testing" + "time" +) + +func TestShuffleDependency_UpdatedAt(t *testing.T) { + // Mock time by using a fixed reference time and comparing behavior + loc := time.UTC + + tests := []struct { + name string + order int + currentTime time.Time + expectedAfter time.Time // The returned time should be >= this + expectedEqual time.Time // For exact matches + }{ + { + name: "hourly - returns start of current hour", + order: ShuffleHourly, + currentTime: time.Date(2024, 6, 15, 14, 30, 45, 0, loc), + expectedEqual: time.Date(2024, 6, 15, 14, 0, 0, 0, loc), + }, + { + name: "daily - returns start of current day", + order: ShuffleDaily, + currentTime: time.Date(2024, 6, 15, 14, 30, 45, 0, loc), + expectedEqual: time.Date(2024, 6, 15, 0, 0, 0, 0, loc), + }, + { + name: "weekly - Monday returns start of Monday", + order: ShuffleWeekly, + currentTime: time.Date(2024, 6, 17, 14, 30, 0, 0, loc), // Monday + expectedEqual: time.Date(2024, 6, 17, 0, 0, 0, 0, loc), + }, + { + name: "weekly - Friday returns start of Monday", + order: ShuffleWeekly, + currentTime: time.Date(2024, 6, 21, 14, 30, 0, 0, loc), // Friday + expectedEqual: time.Date(2024, 6, 17, 0, 0, 0, 0, loc), // Previous Monday + }, + { + name: "weekly - Sunday returns start of previous Monday", + order: ShuffleWeekly, + currentTime: time.Date(2024, 6, 16, 14, 30, 0, 0, loc), // Sunday + expectedEqual: time.Date(2024, 6, 10, 0, 0, 0, 0, loc), // Previous Monday + }, + { + name: "monthly - returns start of current month", + order: ShuffleMonthly, + currentTime: time.Date(2024, 6, 15, 14, 30, 45, 0, loc), + expectedEqual: time.Date(2024, 6, 1, 0, 0, 0, 0, loc), + }, + { + name: "invalid order - returns zero time", + order: 99, + currentTime: time.Date(2024, 6, 15, 14, 30, 45, 0, loc), + expectedEqual: time.Time{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dep := &ShuffleDependency{Order: tt.order} + + // Since UpdatedAt uses time.Now(), we can't test exact values + // but we can test the truncation logic matches expected patterns + result := dep.UpdatedAt() + + // For testing purposes, we verify the logic would work correctly + // by checking if the implementation matches what we expect + // This is a bit indirect, but without mocking time.Now() it's the best we can do + + // Instead, let's verify the truncation happens correctly by checking + // that the result has the expected precision (no sub-second, sub-minute, etc.) + if !tt.expectedEqual.IsZero() { + // For now, just verify it returns a non-zero time for valid orders + if result.IsZero() && tt.order <= 6 && tt.order >= 3 { + t.Errorf("Expected non-zero time for valid shuffle order %d", tt.order) + } + + // Verify truncation worked (no nanoseconds) + if result.Nanosecond() != 0 && tt.order != 99 { + t.Errorf("Expected truncated time (no nanoseconds), got %v", result) + } + } else { + // Invalid order should return zero time + if !result.IsZero() { + t.Errorf("Expected zero time for invalid order, got %v", result) + } + } + }) + } +} + +func TestShuffleDependency_UpdatedAt_Consistency(t *testing.T) { + // Test that calling UpdatedAt multiple times in quick succession + // returns the same truncated time + tests := []struct { + name string + order int + }{ + {"hourly", ShuffleHourly}, + {"daily", ShuffleDaily}, + {"weekly", ShuffleWeekly}, + {"monthly", ShuffleMonthly}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dep := &ShuffleDependency{Order: tt.order} + + // Call multiple times rapidly + first := dep.UpdatedAt() + time.Sleep(10 * time.Millisecond) + second := dep.UpdatedAt() + + // Should return the same truncated time (barring crossing a boundary) + // We can't guarantee they're equal if we cross an hour/day boundary during test + // but we can verify they're both properly truncated + if first.Nanosecond() != 0 { + t.Errorf("First call returned non-truncated time: %v", first) + } + if second.Nanosecond() != 0 { + t.Errorf("Second call returned non-truncated time: %v", second) + } + }) + } +} + +func TestShuffleDependency_UpdatedAt_Staleness(t *testing.T) { + // Test that UpdatedAt correctly triggers staleness detection + loc := time.UTC + + tests := []struct { + name string + order int + sceneTime time.Time + description string + }{ + { + name: "hourly - scene from previous hour should be stale", + order: ShuffleHourly, + sceneTime: time.Now().Add(-2 * time.Hour).Truncate(time.Hour), + description: "scene created 2 hours ago", + }, + { + name: "daily - scene from yesterday should be stale", + order: ShuffleDaily, + sceneTime: time.Now().Add(-25 * time.Hour).Truncate(24 * time.Hour), + description: "scene created yesterday", + }, + { + name: "weekly - scene from last week should be stale", + order: ShuffleWeekly, + sceneTime: time.Now().Add(-8 * 24 * time.Hour), + description: "scene created last week", + }, + { + name: "monthly - scene from last month should be stale", + order: ShuffleMonthly, + sceneTime: time.Date(2024, 1, 15, 12, 0, 0, 0, loc), + description: "scene created in previous month", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dep := &ShuffleDependency{Order: tt.order} + updatedAt := dep.UpdatedAt() + + // For old scenes, UpdatedAt should return a time after the scene creation + // This triggers staleness in UpdateStaleness() + if !updatedAt.After(tt.sceneTime) && !updatedAt.Equal(tt.sceneTime) { + // This might fail if we're in the same period as the old scene + // which is unlikely but possible at period boundaries + t.Logf("Warning: UpdatedAt (%v) not after scene time (%v) - might be at period boundary", + updatedAt, tt.sceneTime) + } + }) + } +} + +func TestShuffleDependency_UpdatedAt_WeekBoundaries(t *testing.T) { + // Test that weekly shuffle correctly identifies Monday as the week start + dep := &ShuffleDependency{Order: ShuffleWeekly} + updatedAt := dep.UpdatedAt() + + // Verify the returned time is a Monday + if updatedAt.Weekday() != time.Monday { + t.Errorf("Weekly shuffle should return Monday, got %v", updatedAt.Weekday()) + } + + // Verify it's at midnight in local timezone + hour, min, sec := updatedAt.Clock() + if hour != 0 || min != 0 || sec != 0 { + t.Errorf("Expected midnight (00:00:00), got %02d:%02d:%02d", + hour, min, sec) + } +} diff --git a/internal/scene/sceneSource.go b/internal/scene/sceneSource.go index 7feb2b6d..87f335ff 100644 --- a/internal/scene/sceneSource.go +++ b/internal/scene/sceneSource.go @@ -79,6 +79,16 @@ func (source *SceneSource) loadScene(config SceneConfig, imageSource *image.Sour scene.Loading = true scene.Search = config.Scene.Search + shuffleSeed := layout.ComputeShuffleSeed(config.Layout.Order, scene.CreatedAt) + + // Add shuffle dependency if order is a shuffle type + switch config.Layout.Order { + case layout.ShuffleHourly, layout.ShuffleDaily, layout.ShuffleWeekly, layout.ShuffleMonthly: + scene.Dependencies = append(scene.Dependencies, &render.ShuffleDependency{ + Order: int(config.Layout.Order), + }) + } + go func() { finished := metrics.Elapsed("scene load " + config.Collection.Id) @@ -134,8 +144,9 @@ func (source *SceneSource) loadScene(config SceneConfig, imageSource *image.Sour if config.Layout.Type == layout.Highlights { infos := imageSource.ListInfosEmb(config.Collection.Dirs, image.ListOptions{ - OrderBy: image.ListOrder(config.Layout.Order), - Limit: config.Collection.Limit, + OrderBy: image.ListOrder(config.Layout.Order), + ShuffleSeed: shuffleSeed, + Limit: config.Collection.Limit, }) layout.LayoutHighlights(infos, config.Layout, &scene, imageSource) @@ -155,9 +166,10 @@ func (source *SceneSource) loadScene(config SceneConfig, imageSource *image.Sour var infos <-chan image.SourcedInfo if expression.Filter.Value == "knn" { infos = imageSource.ListKnn(config.Collection.Dirs, image.ListOptions{ - OrderBy: image.ListOrder(config.Layout.Order), - Limit: config.Collection.Limit, - Expression: expression, + OrderBy: image.ListOrder(config.Layout.Order), + ShuffleSeed: shuffleSeed, + Limit: config.Collection.Limit, + Expression: expression, }) } else { // Normal order @@ -168,11 +180,12 @@ func (source *SceneSource) loadScene(config SceneConfig, imageSource *image.Sour extensions = imageSource.Images.Extensions } infos, deps = config.Collection.GetInfos(imageSource, image.ListOptions{ - OrderBy: image.ListOrder(config.Layout.Order), - Limit: config.Collection.Limit, - Expression: expression, - Embedding: scene.SearchEmbedding, - Extensions: extensions, + OrderBy: image.ListOrder(config.Layout.Order), + ShuffleSeed: shuffleSeed, + Limit: config.Collection.Limit, + Expression: expression, + Embedding: scene.SearchEmbedding, + Extensions: extensions, }) for _, dep := range deps { scene.Dependencies = append(scene.Dependencies, render.Dependency(&dep)) diff --git a/main.go b/main.go index 93eaaa11..0887b6df 100644 --- a/main.go +++ b/main.go @@ -401,6 +401,10 @@ func (*Api) PostScenes(w http.ResponseWriter, r *http.Request) { if data.Layout != "" { sceneConfig.Layout.Type = layout.Type(data.Layout) } + // Apply collection default sort if no query param provided + if data.Sort == nil && sceneConfig.Collection.Sort != "" { + sceneConfig.Layout.Order = layout.OrderFromSort(sceneConfig.Collection.Sort) + } if data.Sort != nil { sceneConfig.Layout.Order = layout.OrderFromSort(string(*data.Sort)) if sceneConfig.Layout.Order == layout.None { @@ -432,13 +436,6 @@ func (*Api) GetScenes(w http.ResponseWriter, r *http.Request, params openapi.Get if params.Layout != nil { sceneConfig.Layout.Type = layout.Type(*params.Layout) } - if params.Sort != nil { - sceneConfig.Layout.Order = layout.OrderFromSort(string(*params.Sort)) - if sceneConfig.Layout.Order == layout.None { - problem(w, r, http.StatusBadRequest, "Invalid sort") - return - } - } if params.Search != nil { sceneConfig.Scene.Search = string(*params.Search) } @@ -452,6 +449,18 @@ func (*Api) GetScenes(w http.ResponseWriter, r *http.Request, params openapi.Get } sceneConfig.Collection = collection + // Apply collection default sort if no query param provided + if params.Sort == nil && collection.Sort != "" { + sceneConfig.Layout.Order = layout.OrderFromSort(collection.Sort) + } + if params.Sort != nil { + sceneConfig.Layout.Order = layout.OrderFromSort(string(*params.Sort)) + if sceneConfig.Layout.Order == layout.None { + problem(w, r, http.StatusBadRequest, "Invalid sort") + return + } + } + // Disregard viewport height for album and timeline layouts // as they are invariant to it switch sceneConfig.Layout.Type { diff --git a/ui/src/components/CollectionView.vue b/ui/src/components/CollectionView.vue index a142dabd..59218d42 100644 --- a/ui/src/components/CollectionView.vue +++ b/ui/src/components/CollectionView.vue @@ -298,6 +298,11 @@ const onSelectTag = async (tag) => { } const sort = computed(() => { + // Use query param if provided (shuffle or explicit sort) + if (route.query.sort) { + return route.query.sort; + } + // Default sorting by layout switch (layout.value) { case "TIMELINE": return "-date"; diff --git a/ui/src/components/DisplaySettings.vue b/ui/src/components/DisplaySettings.vue index d44a4251..717e72dd 100644 --- a/ui/src/components/DisplaySettings.vue +++ b/ui/src/components/DisplaySettings.vue @@ -7,6 +7,13 @@ > Layout + + Shuffle +