initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
286
api/internal/service/link.go
Normal file
286
api/internal/service/link.go
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue