diff --git a/api/gqlgen.yml b/api/gqlgen.yml
index 484a4b23..73a286af 100644
--- a/api/gqlgen.yml
+++ b/api/gqlgen.yml
@@ -32,6 +32,8 @@ models:
fields:
exif:
resolver: true
+ faces:
+ resolver: true
MediaURL:
model: github.com/photoview/photoview/api/graphql/models.MediaURL
MediaEXIF:
diff --git a/api/graphql/generated.go b/api/graphql/generated.go
index a77b6ed9..335e701d 100644
--- a/api/graphql/generated.go
+++ b/api/graphql/generated.go
@@ -94,6 +94,7 @@ type ComplexityRoot struct {
Album func(childComplexity int) int
Downloads func(childComplexity int) int
Exif func(childComplexity int) int
+ Faces func(childComplexity int) int
Favorite func(childComplexity int) int
HighRes func(childComplexity int) int
ID func(childComplexity int) int
@@ -264,6 +265,7 @@ type MediaResolver interface {
Shares(ctx context.Context, obj *models.Media) ([]*models.ShareToken, error)
Downloads(ctx context.Context, obj *models.Media) ([]*models.MediaDownload, error)
+ Faces(ctx context.Context, obj *models.Media) ([]*models.ImageFace, error)
}
type MutationResolver interface {
AuthorizeUser(ctx context.Context, username string, password string) (*models.AuthorizeResult, error)
@@ -518,6 +520,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Media.Exif(childComplexity), true
+ case "Media.faces":
+ if e.complexity.Media.Faces == nil {
+ break
+ }
+
+ return e.complexity.Media.Faces(childComplexity), true
+
case "Media.favorite":
if e.complexity.Media.Favorite == nil {
break
@@ -1723,6 +1732,8 @@ type Media {
shares: [ShareToken!]!
downloads: [MediaDownload!]!
+
+ faces: [ImageFace!]!
}
"EXIF metadata from the camera"
@@ -3786,6 +3797,41 @@ func (ec *executionContext) _Media_downloads(ctx context.Context, field graphql.
return ec.marshalNMediaDownload2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMediaDownloadᚄ(ctx, field.Selections, res)
}
+func (ec *executionContext) _Media_faces(ctx context.Context, field graphql.CollectedField, obj *models.Media) (ret graphql.Marshaler) {
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ fc := &graphql.FieldContext{
+ Object: "Media",
+ Field: field,
+ Args: nil,
+ IsMethod: true,
+ IsResolver: true,
+ }
+
+ ctx = graphql.WithFieldContext(ctx, fc)
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+ ctx = rctx // use context from middleware stack in children
+ return ec.resolvers.Media().Faces(rctx, obj)
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.([]*models.ImageFace)
+ fc.Result = res
+ return ec.marshalNImageFace2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFaceᚄ(ctx, field.Selections, res)
+}
+
func (ec *executionContext) _MediaDownload_title(ctx context.Context, field graphql.CollectedField, obj *models.MediaDownload) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@@ -8993,6 +9039,20 @@ func (ec *executionContext) _Media(ctx context.Context, sel ast.SelectionSet, ob
}
return res
})
+ case "faces":
+ field := field
+ out.Concurrently(i, func() (res graphql.Marshaler) {
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ }
+ }()
+ res = ec._Media_faces(ctx, field, obj)
+ if res == graphql.Null {
+ atomic.AddUint32(&invalids, 1)
+ }
+ return res
+ })
default:
panic("unknown field " + strconv.Quote(field.Name))
}
@@ -10341,6 +10401,53 @@ func (ec *executionContext) marshalNImageFace2ᚕgithubᚗcomᚋphotoviewᚋphot
return ret
}
+func (ec *executionContext) marshalNImageFace2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFaceᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.ImageFace) graphql.Marshaler {
+ ret := make(graphql.Array, len(v))
+ var wg sync.WaitGroup
+ isLen1 := len(v) == 1
+ if !isLen1 {
+ wg.Add(len(v))
+ }
+ for i := range v {
+ i := i
+ fc := &graphql.FieldContext{
+ Index: &i,
+ Result: &v[i],
+ }
+ ctx := graphql.WithFieldContext(ctx, fc)
+ f := func(i int) {
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = nil
+ }
+ }()
+ if !isLen1 {
+ defer wg.Done()
+ }
+ ret[i] = ec.marshalNImageFace2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFace(ctx, sel, v[i])
+ }
+ if isLen1 {
+ f(i)
+ } else {
+ go f(i)
+ }
+
+ }
+ wg.Wait()
+ return ret
+}
+
+func (ec *executionContext) marshalNImageFace2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFace(ctx context.Context, sel ast.SelectionSet, v *models.ImageFace) graphql.Marshaler {
+ if v == nil {
+ if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ return ec._ImageFace(ctx, sel, v)
+}
+
func (ec *executionContext) unmarshalNInt2int(ctx context.Context, v interface{}) (int, error) {
res, err := graphql.UnmarshalInt(v)
return res, graphql.ErrorOnPath(ctx, err)
diff --git a/api/graphql/models/media.go b/api/graphql/models/media.go
index 014fd750..cd8e093d 100644
--- a/api/graphql/models/media.go
+++ b/api/graphql/models/media.go
@@ -27,7 +27,8 @@ type Media struct {
VideoMetadataID *int `gorm:"index"`
VideoMetadata *VideoMetadata `gorm:"constraint:OnDelete:CASCADE;"`
SideCarPath *string
- SideCarHash *string `gorm:"unique"`
+ SideCarHash *string `gorm:"unique"`
+ Faces []*ImageFace `gorm:"constraint:OnDelete:CASCADE;"`
// Only used internally
CounterpartPath *string `gorm:"-"`
diff --git a/api/graphql/resolvers/faces.go b/api/graphql/resolvers/faces.go
index b99b3437..de4328fb 100644
--- a/api/graphql/resolvers/faces.go
+++ b/api/graphql/resolvers/faces.go
@@ -23,8 +23,12 @@ func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagin
userAlbumIDs[i] = album.ID
}
+ imageFaceQuery := r.Database.
+ Joins("Media").
+ Where("media.album_id IN (?)", userAlbumIDs)
+
var imageFaces []*models.ImageFace
- if err := r.Database.Joins("Media").Where("media.album_id IN (?)", userAlbumIDs).Find(&imageFaces).Error; err != nil {
+ if err := imageFaceQuery.Find(&imageFaces).Error; err != nil {
return nil, err
}
@@ -47,8 +51,12 @@ func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagin
i++
}
+ faceGroupQuery := r.Database.
+ Where("id IN (?)", faceGroupIDs).
+ Order("CASE WHEN label IS NULL THEN 1 ELSE 0 END")
+
var faceGroups []*models.FaceGroup
- if err := r.Database.Where("id IN (?)", faceGroupIDs).Find(&faceGroups).Error; err != nil {
+ if err := faceGroupQuery.Find(&faceGroups).Error; err != nil {
return nil, err
}
diff --git a/api/graphql/resolvers/media.go b/api/graphql/resolvers/media.go
index bb0d2706..b32117fb 100644
--- a/api/graphql/resolvers/media.go
+++ b/api/graphql/resolvers/media.go
@@ -208,3 +208,16 @@ func (r *mutationResolver) FavoriteMedia(ctx context.Context, mediaID int, favor
return &media, nil
}
+
+func (r *mediaResolver) Faces(ctx context.Context, media *models.Media) ([]*models.ImageFace, error) {
+ if media.Faces != nil {
+ return media.Faces, nil
+ }
+
+ var faces []*models.ImageFace
+ if err := r.Database.Model(&media).Association("Faces").Find(&faces); err != nil {
+ return nil, err
+ }
+
+ return faces, nil
+}
diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql
index 88a22fdb..bf4fdb88 100644
--- a/api/graphql/schema.graphql
+++ b/api/graphql/schema.graphql
@@ -264,6 +264,8 @@ type Media {
shares: [ShareToken!]!
downloads: [MediaDownload!]!
+
+ faces: [ImageFace!]!
}
"EXIF metadata from the camera"
diff --git a/ui/src/Pages/PeoplePage/PeoplePage.js b/ui/src/Pages/PeoplePage/PeoplePage.js
index dfe41eb9..73a28c8a 100644
--- a/ui/src/Pages/PeoplePage/PeoplePage.js
+++ b/ui/src/Pages/PeoplePage/PeoplePage.js
@@ -48,11 +48,18 @@ const FaceImage = styled(ProtectedImage)`
object-fit: cover;
`
+const FaceLabel = styled.div`
+ color: ${({ labeled }) => (labeled ? 'black' : '#aaa')};
+ margin: 12px 12px 24px;
+ text-align: center;
+`
+
const FaceGroup = ({ group }) => (