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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ PORT=8082
# How often to sync with Immich (in milliseconds, default: 5 minutes)
SYNC_INTERVAL_MS=300000

# "Nearby in time" suggestions: how many hours before/after a photo to search for
# geolocated neighbors (default: 6). Widen it if your photos are geotagged sparsely;
# narrow it to avoid suggesting locations from a different outing the same day.
# SUGGESTIONS_NEIGHBOR_WINDOW_HOURS=6

# Set to true when running behind a reverse proxy that terminates TLS (marks session cookies as Secure)
TRUST_PROXY_TLS=true

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ The app has two parts:
- Drag-and-drop a photo onto the map to assign coordinates
- Geocoding search with autocomplete and history (Nominatim by default, optional HERE and Google Maps providers)
- Favorite places: star locations from search results for quick access
- Smart suggestions: locations from same-day, nearby-day, weekly patterns, frequent spots, and album context
- Smart suggestions: locations from nearby-in-time photos, same-day, nearby-day, weekly patterns, frequent spots, and album context
- GPX import: upload one or multiple GPX tracks to batch-assign coordinates to photos by timestamp

**Photo Browsing**
Expand Down Expand Up @@ -138,6 +138,7 @@ The following Immich permissions are required:
- `FRONTEND_PORT` (default `3032`): Frontend port exposed to the host.
- `REGISTRATION_ENABLED` (default `true`): Set to `false` to disable new users.
- `SYNC_INTERVAL_MS` (default `300000`): Background sync frequency in milliseconds.
- `SUGGESTIONS_NEIGHBOR_WINDOW_HOURS` (default `6`): For "Nearby in time" suggestions, how many hours before/after a photo to search for geolocated neighbors. Widen it if your library is geotagged sparsely; narrow it to avoid suggesting a location from a different outing on the same day.
- `DATA_DIR` (default `/data`): Backend DB path inside container.
- `PORT` (default `8082`): Backend listen port inside container.
- `BACKEND_URL` (frontend): Backend service URL used by the Next.js rewrite, default is `http://backend:8082`.
Expand Down
5 changes: 5 additions & 0 deletions backend/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type Config struct {
HereAPIKey string `env:"HERE_API_KEY"`
GoogleAPIKey string `env:"GOOGLE_API_KEY"`
GeocodeTimeoutSecs int `env:"GEOCODE_TIMEOUT" envDefault:"10"`
NeighborWindowHours int `env:"SUGGESTIONS_NEIGHBOR_WINDOW_HOURS" envDefault:"6"`
Debug bool `env:"DEBUG" envDefault:"false"`

defaultTimezoneLocation *time.Location
Expand All @@ -51,6 +52,10 @@ func loadConfig() (*Config, error) {
return nil, fmt.Errorf("GEOCODE_TIMEOUT must be > 0, got %d", cfg.GeocodeTimeoutSecs)
}

if cfg.NeighborWindowHours <= 0 {
return nil, fmt.Errorf("SUGGESTIONS_NEIGHBOR_WINDOW_HOURS must be > 0, got %d", cfg.NeighborWindowHours)
}

if !cfg.TrustProxyTLS && !cfg.AllowInsecure {
return nil, fmt.Errorf("TRUST_PROXY_TLS is false and no TLS is configured; set ALLOW_INSECURE=true to run without TLS")
}
Expand Down
15 changes: 15 additions & 0 deletions backend/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,18 @@ func TestLoadConfigRegistrationDisabled(t *testing.T) {
t.Error("expected RegistrationEnabled to be false")
}
}

func TestLoadConfigNeighborWindowOverride(t *testing.T) {
withCleanWorkDir(t)
t.Setenv("IMMICH_URL", "http://test:2283")
t.Setenv("ENCRYPTION_KEY", "test-secret")
t.Setenv("SUGGESTIONS_NEIGHBOR_WINDOW_HOURS", "24")

cfg, err := loadConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.NeighborWindowHours != 24 {
t.Errorf("expected NeighborWindowHours 24, got %d", cfg.NeighborWindowHours)
}
}
54 changes: 54 additions & 0 deletions backend/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import (
"database/sql"
"fmt"
"log"
"math"
"os"
"path/filepath"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -470,6 +472,58 @@ func (d *Database) getSameDayAssets(ctx context.Context, userID, dateTimeOrigina
return scanAssetRows(rows)
}

func (d *Database) getNeighborAssets(ctx context.Context, userID, excludeAssetID, dateTimeOriginal string, windowHours, limit int) ([]AssetRow, error) {
refTime, err := parseTimestamp(dateTimeOriginal)
if err != nil {
return nil, fmt.Errorf("failed to parse dateTimeOriginal: %w", err)
}
rangeStart := refTime.Add(-time.Duration(windowHours) * time.Hour).Format(time.RFC3339)
rangeEnd := refTime.Add(time.Duration(windowHours) * time.Hour).Format(time.RFC3339)

rows, err := d.db.QueryContext(ctx,
`SELECT `+assetColumns+`
FROM assets
WHERE userID = ? AND immichID != ? AND latitude IS NOT NULL AND longitude IS NOT NULL
AND dateTimeOriginal IS NOT NULL
AND stackPrimaryAssetID IS NULL
AND dateTimeOriginal BETWEEN ? AND ?`+hiddenLibraryFilter,
userID, excludeAssetID, rangeStart, rangeEnd,
)
if err != nil {
return nil, err
}
defer rows.Close()

assets, err := scanAssetRows(rows)
if err != nil {
return nil, err
}

sort.Slice(assets, func(i, j int) bool {
return absTimeDiff(refTime, assets[i].DateTimeOriginal) < absTimeDiff(refTime, assets[j].DateTimeOriginal)
})

if limit >= 0 && len(assets) > limit {
assets = assets[:limit]
}
return assets, nil
}

func absTimeDiff(refTime time.Time, ts *string) time.Duration {
if ts == nil {
return time.Duration(math.MaxInt64)
}
t, err := parseTimestamp(*ts)
if err != nil {
return time.Duration(math.MaxInt64)
}
diff := refTime.Sub(t)
if diff < 0 {
diff = -diff
}
return diff
}

func (d *Database) getFavoritePlaces(ctx context.Context, userID string) ([]FavoritePlaceRow, error) {
rows, err := d.db.QueryContext(ctx,
"SELECT ID, latitude, longitude, displayName, createdAt FROM favoritePlaces WHERE userID = ? ORDER BY createdAt DESC LIMIT 30",
Expand Down
69 changes: 69 additions & 0 deletions backend/database_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,75 @@ func TestGetSameDayAssetsRange(t *testing.T) {
}
}

func TestGetNeighborAssetsOrderingAndWindow(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()

ref := "2024-06-15T12:00:00Z"
self := ref
after5 := time.Date(2024, 6, 15, 12, 5, 0, 0, time.UTC).Format(time.RFC3339)
before2 := time.Date(2024, 6, 15, 11, 58, 0, 0, time.UTC).Format(time.RFC3339)
after3h := time.Date(2024, 6, 15, 15, 0, 0, 0, time.UTC).Format(time.RFC3339)
outside := time.Date(2024, 6, 14, 12, 0, 0, 0, time.UTC).Format(time.RFC3339) // 24h before

db.upsertAssets(ctx, testUserID, []AssetRow{
{
ImmichID: "self", Type: "IMAGE", OriginalFileName: "self.jpg",
FileCreatedAt: self, Latitude: ptr(48.86), Longitude: ptr(2.36),
DateTimeOriginal: &self,
},
{
ImmichID: "after5", Type: "IMAGE", OriginalFileName: "after5.jpg",
FileCreatedAt: after5, Latitude: ptr(48.86), Longitude: ptr(2.36),
DateTimeOriginal: &after5,
},
{
ImmichID: "before2", Type: "IMAGE", OriginalFileName: "before2.jpg",
FileCreatedAt: before2, Latitude: ptr(48.85), Longitude: ptr(2.35),
DateTimeOriginal: &before2,
},
{
ImmichID: "after3h", Type: "IMAGE", OriginalFileName: "after3h.jpg",
FileCreatedAt: after3h, Latitude: ptr(48.87), Longitude: ptr(2.37),
DateTimeOriginal: &after3h,
},
{
ImmichID: "outside", Type: "IMAGE", OriginalFileName: "outside.jpg",
FileCreatedAt: outside, Latitude: ptr(40.71), Longitude: ptr(-74.0),
DateTimeOriginal: &outside,
},
})

// 6h window keeps the three in-window photos, excludes the 24h-away one
// and the reference asset itself
assets, err := db.getNeighborAssets(ctx, testUserID, "self", ref, 6, 6)
if err != nil {
t.Fatalf("getNeighborAssets: %v", err)
}
if len(assets) != 3 {
t.Fatalf("expected 3 neighbors within 6h window, got %d", len(assets))
}
// Closest-first: before2 (2m), after5 (5m), after3h (3h).
wantOrder := []string{"before2", "after5", "after3h"}
for i, want := range wantOrder {
if assets[i].ImmichID != want {
t.Errorf("position %d: expected %q, got %q", i, want, assets[i].ImmichID)
}
}

// limit truncates to the closest N.
assets, err = db.getNeighborAssets(ctx, testUserID, "self", ref, 6, 2)
if err != nil {
t.Fatalf("getNeighborAssets limit=2: %v", err)
}
if len(assets) != 2 {
t.Fatalf("expected limit to truncate to 2, got %d", len(assets))
}
if assets[0].ImmichID != "before2" || assets[1].ImmichID != "after5" {
t.Errorf("expected closest two [before2 after5], got [%s %s]", assets[0].ImmichID, assets[1].ImmichID)
}
}

func TestAlbumDiffReplaceAssets(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
Expand Down
1 change: 1 addition & 0 deletions backend/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const (
maxPageInfoPageSize = 500
defaultPageInfoPageSize = 90
frequentLocationsLimit = 5
neighborLimit = 6
maxClusterResults = 5
defaultMapMarkersLimit = maxMapMarkers
syncVersion = 1
Expand Down
7 changes: 5 additions & 2 deletions backend/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func newTestHandlers(t *testing.T) (*Handlers, *http.ServeMux) {
}
syncService := newSyncService(db, factory, newNominatimClient(10 * time.Second))
syncService.shutdownCtx = context.Background()
suggestions := newSuggestionService(db)
suggestions := newSuggestionService(db, testNeighborWindow)
handlers := newHandlers(db, factory, "http://external:2283", syncService, suggestions, nil, newNominatimClient(10*time.Second))

mux := http.NewServeMux()
Expand Down Expand Up @@ -380,7 +380,7 @@ func newTestHandlersWithMockImmich(t *testing.T, immichHandler http.HandlerFunc)
}
syncService := newSyncService(db, factory, newNominatimClient(10 * time.Second))
syncService.shutdownCtx = context.Background()
suggestions := newSuggestionService(db)
suggestions := newSuggestionService(db, testNeighborWindow)
handlers := newHandlers(db, factory, "http://external:2283", syncService, suggestions, nil, newNominatimClient(10*time.Second))

mux := http.NewServeMux()
Expand Down Expand Up @@ -732,6 +732,9 @@ func TestHandleGetSuggestionsSuccess(t *testing.T) {
if resp.SameDayClusters == nil {
t.Error("expected non-nil sameDayClusters")
}
if resp.NeighborClusters == nil {
t.Error("expected non-nil neighborClusters")
}
}

func TestHandleGetAssetPageInfoSuccess(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions backend/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type HandlerStore interface {
type SuggestionStore interface {
getAssetByID(ctx context.Context, userID, immichID string) (*AssetRow, error)
getSameDayAssets(ctx context.Context, userID, dateTimeOriginal string, hoursRange int) ([]AssetRow, error)
getNeighborAssets(ctx context.Context, userID, excludeAssetID, dateTimeOriginal string, windowHours, limit int) ([]AssetRow, error)
getFrequentLocations(ctx context.Context, userID string, limit int) ([]FrequentLocationRow, error)
getAlbumUpdatedAt(ctx context.Context, userID, albumID string) (string, error)
getGeolocatedAssetsByAlbum(ctx context.Context, userID, albumID string) ([]AssetRow, error)
Expand Down
2 changes: 1 addition & 1 deletion backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func main() {
}, geocodeTimeout)
log.Printf("[Geocode] Provider: %s (timeout: %v)", describeProvider(geocoder), geocodeTimeout)
syncService := newSyncService(db, immichFactory, geocoder)
suggestions := newSuggestionService(db)
suggestions := newSuggestionService(db, cfg.NeighborWindowHours)
handlers := newHandlers(db, immichFactory, cfg.ImmichExternalURL, syncService, suggestions, cfg.defaultTimezoneLocation, geocoder)
libraryHandlers := newLibraryHandlers(db, immichFactory, syncService)
authHandlers := newAuthHandlers(db, immichFactory, syncService, cfg.RegistrationEnabled, !cfg.AllowInsecure)
Expand Down
37 changes: 36 additions & 1 deletion backend/suggestionService.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ type albumClusterCache struct {

type SuggestionService struct {
db SuggestionStore
neighborWindow int
albumClustersMu sync.Mutex
albumClustersMap map[string]albumClusterCache
}

func newSuggestionService(db SuggestionStore) *SuggestionService {
func newSuggestionService(db SuggestionStore, neighborWindowHours int) *SuggestionService {
return &SuggestionService{
db: db,
neighborWindow: neighborWindowHours,
albumClustersMap: make(map[string]albumClusterCache),
}
}
Expand All @@ -51,6 +53,7 @@ func (s *SuggestionService) getSuggestions(ctx context.Context, userID, assetID
WeeklyClusters: []LocationCluster{},
FrequentLocations: []LocationCluster{},
AlbumClusters: []LocationCluster{},
NeighborClusters: []LocationCluster{},
}

if albumID != "" {
Expand All @@ -75,6 +78,12 @@ func (s *SuggestionService) getSuggestions(ctx context.Context, userID, assetID
response.WeeklyClusters = clusterAssets(weeklyAssets)
}

if neighborAssets, err := s.db.getNeighborAssets(ctx, userID, assetID, *dateRef, s.neighborWindow, neighborLimit); err != nil {
log.Printf("[Suggest] Failed to get neighbor assets: %v", err)
} else if parseErr == nil {
response.NeighborClusters = buildNeighborPoints(neighborAssets, refTime)
}

freqLocs, err := s.db.getFrequentLocations(ctx, userID, frequentLocationsLimit)
if err != nil {
log.Printf("[Suggest] Failed to get frequent locations: %v", err)
Expand Down Expand Up @@ -143,6 +152,32 @@ func filterByHourRange(assets []AssetRow, refTime time.Time, hoursRange int) []A
return filtered
}

func buildNeighborPoints(assets []AssetRow, refTime time.Time) []LocationCluster {
points := make([]LocationCluster, 0, len(assets))
for _, a := range assets {
if a.Latitude == nil || a.Longitude == nil || a.DateTimeOriginal == nil {
continue
}
t, err := parseTimestamp(*a.DateTimeOriginal)
if err != nil {
continue
}
offset := int64(t.Sub(refTime).Seconds())
label := buildMetadataLabel(a.City, a.State, a.Country)
if label == "" {
label = formatCoords(*a.Latitude, *a.Longitude)
}
points = append(points, LocationCluster{
Latitude: *a.Latitude,
Longitude: *a.Longitude,
Label: label,
Count: 1,
SecondsFromRef: &offset,
})
}
return points
}

func clusterAssets(assets []AssetRow) []LocationCluster {
type clusterData struct {
latSum float64
Expand Down
Loading
Loading