package service import ( "context" "sort" "go-places-api/internal/domain" "go-places-api/internal/events" "go-places-api/internal/geo" "go-places-api/internal/store" ) // maxLinkedImagesPerImage bounds how many links a single picture is given, so // a dense cluster of pictures taken from the same spot does not produce a // quadratic blow-up in the response. const maxLinkedImagesPerImage = 50 // LinkReasons explains why two pictures were linked. const ( // ReasonSamePlace means both pictures belong to the same place record. ReasonSamePlace = "same-place" // ReasonWithinRadius means the pictures were taken within the place's // linking radius of each other, even though they belong to different // place records. This is what catches the same spot catalogued twice. ReasonWithinRadius = "within-radius" ) // linkAfterUpload links a freshly stored picture to the others taken at the // same place. Failures are the caller's to log rather than surface, because the // picture itself is already safely stored. func (s *Service) linkAfterUpload(ctx context.Context, img domain.Image) error { place, err := s.store.GetPlace(ctx, img.PlaceID) if err != nil { return mapStoreError(err, "place") } center := effectiveCoordinate(img, place.Coordinate) if center == nil { // Without a location there is nothing to compare against. This is // common and not an error. return nil } radius := place.LinkRadiusMeters if radius <= 0 { radius = s.cfg.DefaultLinkRadiusMeters } candidates, err := s.store.ImagesWithin(ctx, store.NearbyQuery{ Center: *center, RadiusMeters: radius, }) if err != nil { return mapStoreError(err, "image") } links := LinksFor(img, candidates, place, radius) if len(links) == 0 { return nil } // Persist only the cross-place relationships, since a picture inside its own // place is already known from the place record. Storing the evidence makes // the link discoverable without re-running the spatial query. for _, link := range links { if link.SamePlace { continue } if err := s.store.CreateImageLink(ctx, img.ID, link.RelatedImageID, link.DistanceMeters, link.Reason); err != nil { s.logger.Warn("failed to record image link", "imageId", img.ID, "relatedImageId", link.RelatedImageID, "error", err) } } s.publish(ctx, domain.User{}, events.TopicImageLinked, map[string]any{ "imageId": img.ID, "placeId": img.PlaceID, "links": len(links), "distance": nearestDistance(links), }) return nil } // LinksFor computes the links for one picture against a candidate set. It is // exported through the service so the GraphQL layer can reuse exactly the rules // the upload path applies. // // A link is created when the two pictures are within radius. Pictures in the // same place are linked unconditionally, since sharing a place record is itself // the evidence; pictures in different places are linked only when their capture // points are genuinely close. func LinksFor(img domain.Image, candidates []domain.Image, place domain.Place, radius float64) []domain.Link { if radius <= 0 { return nil } center := effectiveCoordinate(img, place.Coordinate) if center == nil { return nil } var links []domain.Link for _, other := range candidates { if other.ID == img.ID { continue } samePlace := other.PlaceID == img.PlaceID if samePlace { links = append(links, domain.Link{ ImageID: img.ID, RelatedImageID: other.ID, DistanceMeters: 0, SamePlace: true, Reason: ReasonSamePlace, }) continue } otherCenter := effectiveCoordinate(other, nil) if otherCenter == nil { continue } distance := geo.DistanceMeters(*center, *otherCenter) if distance > radius { continue } links = append(links, domain.Link{ ImageID: img.ID, RelatedImageID: other.ID, DistanceMeters: geo.Round(distance), SamePlace: false, Reason: ReasonWithinRadius, }) } // Nearest first, then by identifier so the ordering is stable. sort.SliceStable(links, func(i, j int) bool { if links[i].DistanceMeters != links[j].DistanceMeters { return links[i].DistanceMeters < links[j].DistanceMeters } return links[i].RelatedImageID < links[j].RelatedImageID }) if len(links) > maxLinkedImagesPerImage { links = links[:maxLinkedImagesPerImage] } return links } // LinkedImages returns the other pictures taken at the same place as img. // // Pictures within the same place record are always included. Pictures from // other places are included when their recorded capture point lies inside the // radius, which is what surfaces pictures catalogued under separate places at // the same coordinates. func (s *Service) LinkedImages(ctx context.Context, img domain.Image) ([]domain.Image, error) { place, err := s.store.GetPlace(ctx, img.PlaceID) if err != nil { return nil, mapStoreError(err, "place") } center := effectiveCoordinate(img, place.Coordinate) radius := place.LinkRadiusMeters if radius <= 0 { radius = s.cfg.DefaultLinkRadiusMeters } if center == nil || radius <= 0 { // Without a coordinate there is nothing to search with, but the // pictures sharing this place are still linked by definition. return siblingsOnly(img, place) } seen := map[string]bool{img.ID: true} out := make([]domain.Image, 0, 8) for _, sibling := range place.Images { if seen[sibling.ID] { continue } seen[sibling.ID] = true out = append(out, decorate(sibling, s.ImageURL(sibling.StorageKey))) } candidates, err := s.store.ImagesWithin(ctx, store.NearbyQuery{ Center: *center, RadiusMeters: radius, }) if err != nil { return nil, mapStoreError(err, "image") } for _, other := range candidates { if seen[other.ID] { continue } otherCenter := effectiveCoordinate(other, nil) if otherCenter == nil || !geo.WithinRadius(*center, *otherCenter, radius) { continue } seen[other.ID] = true out = append(out, decorate(other, s.ImageURL(other.StorageKey))) if len(out) >= maxLinkedImagesPerImage { break } } return out, nil } // siblingsOnly returns the other pictures in the same place, used when no // coordinate is available to search with. func siblingsOnly(img domain.Image, place domain.Place) ([]domain.Image, error) { out := make([]domain.Image, 0, len(place.Images)) for _, sibling := range place.Images { if sibling.ID == img.ID { continue } out = append(out, sibling) } return out, nil } // LinksForImage returns the computed links for one picture, which is what the // GraphQL "links" field exposes. func (s *Service) LinksForImage(ctx context.Context, imageID string) ([]domain.Link, error) { img, err := s.store.GetImage(ctx, imageID) if err != nil { return nil, mapStoreError(err, "image") } place, err := s.store.GetPlace(ctx, img.PlaceID) if err != nil { return nil, mapStoreError(err, "place") } center := effectiveCoordinate(img, place.Coordinate) radius := place.LinkRadiusMeters if radius <= 0 { radius = s.cfg.DefaultLinkRadiusMeters } if center == nil { links := make([]domain.Link, 0, len(place.Images)) for _, sibling := range place.Images { if sibling.ID == img.ID { continue } links = append(links, domain.Link{ ImageID: img.ID, RelatedImageID: sibling.ID, SamePlace: true, Reason: ReasonSamePlace, }) } return links, nil } candidates, err := s.store.ImagesWithin(ctx, store.NearbyQuery{ Center: *center, RadiusMeters: radius, }) if err != nil { return nil, mapStoreError(err, "image") } return LinksFor(img, candidates, place, radius), nil } // ImageLinks reads back the links recorded for a picture. func (s *Service) ImageLinks(ctx context.Context, imageID string) ([]domain.Link, error) { links, err := s.store.ListImageLinks(ctx, imageID) if err != nil { return nil, mapStoreError(err, "image link") } return links, nil } func nearestDistance(links []domain.Link) float64 { nearest := 0.0 for i, l := range links { if i == 0 || l.DistanceMeters < nearest { nearest = l.DistanceMeters } } return nearest } // decorate copies an image and fills in the public URL, which is derived from // the storage key and therefore not stored. func decorate(img domain.Image, url string) domain.Image { img.URL = url return img }