go-places/api/internal/service/link.go
2026-10-07 15:14:50 +02:00

286 lines
8.2 KiB
Go

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
}