287 lines
8.2 KiB
Go
287 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
|
||
|
|
}
|