diff --git a/api/graphql/generated.go b/api/graphql/generated.go index 525075c7..ee6d77a5 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -45,6 +45,7 @@ type ResolverRoot interface { Mutation() MutationResolver Query() QueryResolver ShareToken() ShareTokenResolver + SiteInfo() SiteInfoResolver Subscription() SubscriptionResolver User() UserResolver } @@ -220,6 +221,7 @@ type ComplexityRoot struct { SiteInfo struct { ConcurrentWorkers func(childComplexity int) int + FaceDetectionEnabled func(childComplexity int) int InitialSetup func(childComplexity int) int PeriodicScanInterval func(childComplexity int) int } @@ -338,6 +340,9 @@ type QueryResolver interface { type ShareTokenResolver interface { HasPassword(ctx context.Context, obj *models.ShareToken) (bool, error) } +type SiteInfoResolver interface { + FaceDetectionEnabled(ctx context.Context, obj *models.SiteInfo) (bool, error) +} type SubscriptionResolver interface { Notification(ctx context.Context) (<-chan *models.Notification, error) } @@ -1369,6 +1374,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.SiteInfo.ConcurrentWorkers(childComplexity), true + case "SiteInfo.faceDetectionEnabled": + if e.complexity.SiteInfo.FaceDetectionEnabled == nil { + break + } + + return e.complexity.SiteInfo.FaceDetectionEnabled(childComplexity), true + case "SiteInfo.initialSetup": if e.complexity.SiteInfo.InitialSetup == nil { break @@ -1821,7 +1833,10 @@ type ShareToken { "General information about the site" type SiteInfo { + "Whether or not the initial setup wizard should be shown" initialSetup: Boolean! + "Whether or not face detection is enabled and working" + faceDetectionEnabled: Boolean! "How often automatic scans should be initiated in seconds" periodicScanInterval: Int! @isAdmin "How many max concurrent scanner jobs that should run at once" @@ -7862,6 +7877,41 @@ func (ec *executionContext) _SiteInfo_initialSetup(ctx context.Context, field gr return ec.marshalNBoolean2bool(ctx, field.Selections, res) } +func (ec *executionContext) _SiteInfo_faceDetectionEnabled(ctx context.Context, field graphql.CollectedField, obj *models.SiteInfo) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "SiteInfo", + 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.SiteInfo().FaceDetectionEnabled(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.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + func (ec *executionContext) _SiteInfo_periodicScanInterval(ctx context.Context, field graphql.CollectedField, obj *models.SiteInfo) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -11133,17 +11183,31 @@ func (ec *executionContext) _SiteInfo(ctx context.Context, sel ast.SelectionSet, case "initialSetup": out.Values[i] = ec._SiteInfo_initialSetup(ctx, field, obj) if out.Values[i] == graphql.Null { - invalids++ + atomic.AddUint32(&invalids, 1) } + case "faceDetectionEnabled": + 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._SiteInfo_faceDetectionEnabled(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "periodicScanInterval": out.Values[i] = ec._SiteInfo_periodicScanInterval(ctx, field, obj) if out.Values[i] == graphql.Null { - invalids++ + atomic.AddUint32(&invalids, 1) } case "concurrentWorkers": out.Values[i] = ec._SiteInfo_concurrentWorkers(ctx, field, obj) if out.Values[i] == graphql.Null { - invalids++ + atomic.AddUint32(&invalids, 1) } default: panic("unknown field " + strconv.Quote(field.Name)) diff --git a/api/graphql/resolvers/faces.go b/api/graphql/resolvers/faces.go index c431a13d..34fe1523 100644 --- a/api/graphql/resolvers/faces.go +++ b/api/graphql/resolvers/faces.go @@ -32,6 +32,10 @@ func (r imageFaceResolver) FaceGroup(ctx context.Context, obj *models.ImageFace) return obj.FaceGroup, nil } + if face_detection.GlobalFaceDetector == nil { + return nil, errors.New("face detector not initialized") + } + var faceGroup models.FaceGroup if err := r.Database.Model(&obj).Association("FaceGroup").Find(&faceGroup); err != nil { return nil, err @@ -48,6 +52,10 @@ func (r faceGroupResolver) ImageFaces(ctx context.Context, obj *models.FaceGroup return nil, errors.New("unauthorized") } + if face_detection.GlobalFaceDetector == nil { + return nil, errors.New("face detector not initialized") + } + if err := user.FillAlbums(r.Database); err != nil { return nil, err } @@ -78,6 +86,10 @@ func (r faceGroupResolver) ImageFaceCount(ctx context.Context, obj *models.FaceG return -1, errors.New("unauthorized") } + if face_detection.GlobalFaceDetector == nil { + return -1, errors.New("face detector not initialized") + } + if err := user.FillAlbums(r.Database); err != nil { return -1, err } @@ -107,6 +119,10 @@ func (r *queryResolver) FaceGroup(ctx context.Context, id int) (*models.FaceGrou return nil, errors.New("unauthorized") } + if face_detection.GlobalFaceDetector == nil { + return nil, errors.New("face detector not initialized") + } + if err := user.FillAlbums(r.Database); err != nil { return nil, err } @@ -135,6 +151,10 @@ func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagin return nil, errors.New("unauthorized") } + if face_detection.GlobalFaceDetector == nil { + return nil, errors.New("face detector not initialized") + } + if err := user.FillAlbums(r.Database); err != nil { return nil, err } @@ -168,6 +188,10 @@ func (r *mutationResolver) SetFaceGroupLabel(ctx context.Context, faceGroupID in return nil, errors.New("unauthorized") } + if face_detection.GlobalFaceDetector == nil { + return nil, errors.New("face detector not initialized") + } + faceGroup, err := userOwnedFaceGroup(r.Database, user, faceGroupID) if err != nil { return nil, err @@ -186,6 +210,10 @@ func (r *mutationResolver) CombineFaceGroups(ctx context.Context, destinationFac return nil, errors.New("unauthorized") } + if face_detection.GlobalFaceDetector == nil { + return nil, errors.New("face detector not initialized") + } + destinationFaceGroup, err := userOwnedFaceGroup(r.Database, user, destinationFaceGroupID) if err != nil { return nil, err @@ -223,6 +251,10 @@ func (r *mutationResolver) MoveImageFaces(ctx context.Context, imageFaceIDs []in return nil, errors.New("unauthorized") } + if face_detection.GlobalFaceDetector == nil { + return nil, errors.New("face detector not initialized") + } + userOwnedImageFaceIDs := make([]int, 0) var destFaceGroup *models.FaceGroup @@ -290,6 +322,10 @@ func (r *mutationResolver) RecognizeUnlabeledFaces(ctx context.Context) ([]*mode return nil, errors.New("unauthorized") } + if face_detection.GlobalFaceDetector == nil { + return nil, errors.New("face detector not initialized") + } + var updatedImageFaces []*models.ImageFace transactionError := r.Database.Transaction(func(tx *gorm.DB) error { @@ -312,6 +348,10 @@ func (r *mutationResolver) DetachImageFaces(ctx context.Context, imageFaceIDs [] return nil, errors.New("unauthorized") } + if face_detection.GlobalFaceDetector == nil { + return nil, errors.New("face detector not initialized") + } + userOwnedImageFaceIDs := make([]int, 0) newFaceGroup := models.FaceGroup{} diff --git a/api/graphql/resolvers/media.go b/api/graphql/resolvers/media.go index 4f30d6ba..da62d300 100644 --- a/api/graphql/resolvers/media.go +++ b/api/graphql/resolvers/media.go @@ -8,6 +8,7 @@ import ( api "github.com/photoview/photoview/api/graphql" "github.com/photoview/photoview/api/graphql/auth" "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/scanner/face_detection" "github.com/pkg/errors" "gorm.io/gorm/clause" ) @@ -228,6 +229,10 @@ func (r *mutationResolver) FavoriteMedia(ctx context.Context, mediaID int, favor } func (r *mediaResolver) Faces(ctx context.Context, media *models.Media) ([]*models.ImageFace, error) { + if face_detection.GlobalFaceDetector == nil { + return []*models.ImageFace{}, nil + } + if media.Faces != nil { return media.Faces, nil } diff --git a/api/graphql/resolvers/root.go b/api/graphql/resolvers/root.go index c8e3dca4..1ffc098f 100644 --- a/api/graphql/resolvers/root.go +++ b/api/graphql/resolvers/root.go @@ -1,10 +1,7 @@ package resolvers import ( - "context" - api "github.com/photoview/photoview/api/graphql" - "github.com/photoview/photoview/api/graphql/models" "gorm.io/gorm" ) @@ -35,7 +32,3 @@ type queryResolver struct{ *Resolver } type subscriptionResolver struct { Resolver *Resolver } - -func (r *queryResolver) SiteInfo(ctx context.Context) (*models.SiteInfo, error) { - return models.GetSiteInfo(r.Database) -} diff --git a/api/graphql/resolvers/site_info.go b/api/graphql/resolvers/site_info.go new file mode 100644 index 00000000..a562e20e --- /dev/null +++ b/api/graphql/resolvers/site_info.go @@ -0,0 +1,25 @@ +package resolvers + +import ( + "context" + + api "github.com/photoview/photoview/api/graphql" + "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/scanner/face_detection" +) + +func (r *queryResolver) SiteInfo(ctx context.Context) (*models.SiteInfo, error) { + return models.GetSiteInfo(r.Database) +} + +type SiteInfoResolver struct { + *Resolver +} + +func (r *Resolver) SiteInfo() api.SiteInfoResolver { + return &SiteInfoResolver{r} +} + +func (SiteInfoResolver) FaceDetectionEnabled(ctx context.Context, obj *models.SiteInfo) (bool, error) { + return face_detection.GlobalFaceDetector != nil, nil +} diff --git a/api/graphql/resolvers/user.go b/api/graphql/resolvers/user.go index 23bf3be3..2c52d4ef 100644 --- a/api/graphql/resolvers/user.go +++ b/api/graphql/resolvers/user.go @@ -343,8 +343,10 @@ func (r *mutationResolver) UserRemoveRootAlbum(ctx context.Context, userID int, } // Reload faces as media might have been deleted - if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(r.Database); err != nil { - return nil, err + if face_detection.GlobalFaceDetector == nil { + if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(r.Database); err != nil { + return nil, err + } } } diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 313ed04a..b0828dde 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -68,7 +68,7 @@ type Query { "Get media owned by the logged in user, returned in GeoJson format" myMediaGeoJson: Any! @isAuthorized "Get the mapbox api token, returns null if mapbox is not enabled" - mapboxToken: String + mapboxToken: String @isAuthorized shareToken(credentials: ShareTokenCredentials!): ShareToken! shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean! @@ -201,7 +201,10 @@ type ShareToken { "General information about the site" type SiteInfo { + "Whether or not the initial setup wizard should be shown" initialSetup: Boolean! + "Whether or not face detection is enabled and working" + faceDetectionEnabled: Boolean! "How often automatic scans should be initiated in seconds" periodicScanInterval: Int! @isAdmin "How many max concurrent scanner jobs that should run at once" diff --git a/api/scanner/cleanup_media.go b/api/scanner/cleanup_media.go index 25473b66..94926721 100644 --- a/api/scanner/cleanup_media.go +++ b/api/scanner/cleanup_media.go @@ -53,8 +53,10 @@ func CleanupMedia(db *gorm.DB, albumId int, albumMedia []*models.Media) []error } // Reload faces after deleting media - if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil { - deleteErrors = append(deleteErrors, errors.Wrap(err, "reload faces from database")) + if face_detection.GlobalFaceDetector != nil { + if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil { + deleteErrors = append(deleteErrors, errors.Wrap(err, "reload faces from database")) + } } } @@ -124,8 +126,10 @@ func deleteOldUserAlbums(db *gorm.DB, scannedAlbums []*models.Album, user *model } // Reload faces after deleting albums - if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil { - deleteErrors = append(deleteErrors, err) + if face_detection.GlobalFaceDetector == nil { + if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil { + deleteErrors = append(deleteErrors, err) + } } return deleteErrors diff --git a/api/scanner/face_detection/face_detector.go b/api/scanner/face_detection/face_detector.go index 3cecbe7c..506cb887 100644 --- a/api/scanner/face_detection/face_detector.go +++ b/api/scanner/face_detection/face_detector.go @@ -19,9 +19,13 @@ type FaceDetector struct { imageFaceIDs []int } -var GlobalFaceDetector FaceDetector +var GlobalFaceDetector *FaceDetector = nil func InitializeFaceDetector(db *gorm.DB) error { + if utils.EnvDisableFaceRecognition.GetBool() { + log.Printf("Face detection disabled (%s=1)\n", utils.EnvDisableFaceRecognition.GetName()) + return nil + } log.Println("Initializing face detector") @@ -35,7 +39,7 @@ func InitializeFaceDetector(db *gorm.DB) error { return errors.Wrap(err, "get face detection samples from database") } - GlobalFaceDetector = FaceDetector{ + GlobalFaceDetector = &FaceDetector{ rec: rec, faceDescriptors: faceDescriptors, faceGroupIDs: faceGroupIDs, diff --git a/api/scanner/media_encoding/executable_worker/executable_worker.go b/api/scanner/media_encoding/executable_worker/executable_worker.go index 6748d15e..c718012a 100644 --- a/api/scanner/media_encoding/executable_worker/executable_worker.go +++ b/api/scanner/media_encoding/executable_worker/executable_worker.go @@ -8,6 +8,7 @@ import ( "os/exec" "strings" + "github.com/photoview/photoview/api/utils" "github.com/pkg/errors" "gopkg.in/vansante/go-ffprobe.v2" ) @@ -33,6 +34,11 @@ type FfmpegWorker struct { } func newDarktableWorker() *DarktableWorker { + if utils.EnvDisableRawProcessing.GetBool() { + log.Printf("Executable worker disabled (%s=1): darktable\n", utils.EnvDisableRawProcessing.GetName()) + return nil + } + path, err := exec.LookPath("darktable-cli") if err != nil { log.Println("Executable worker not found: darktable") @@ -54,6 +60,11 @@ func newDarktableWorker() *DarktableWorker { } func newFfmpegWorker() *FfmpegWorker { + if utils.EnvDisableVideoEncoding.GetBool() { + log.Printf("Executable worker disabled (%s=1): ffmpeg\n", utils.EnvDisableVideoEncoding.GetName()) + return nil + } + path, err := exec.LookPath("ffmpeg") if err != nil { log.Println("Executable worker not found: ffmpeg") diff --git a/api/scanner/scanner_album.go b/api/scanner/scanner_album.go index e183ae8d..52861e74 100644 --- a/api/scanner/scanner_album.go +++ b/api/scanner/scanner_album.go @@ -141,6 +141,9 @@ func scanAlbum(album *models.Album, cache *scanner_cache.AlbumScannerCache, db * if processing_was_needed && media.Type == models.MediaTypePhoto { go func(media *models.Media) { + if face_detection.GlobalFaceDetector == nil { + return + } if err := face_detection.GlobalFaceDetector.DetectFaces(db, media); err != nil { scanner_utils.ScannerError("Error detecting faces in image (%s): %s", media.Path, err) } diff --git a/api/utils/environment_variables.go b/api/utils/environment_variables.go index f0777f39..612ff793 100644 --- a/api/utils/environment_variables.go +++ b/api/utils/environment_variables.go @@ -1,6 +1,9 @@ package utils -import "os" +import ( + "os" + "strings" +) // EnvironmentVariable represents the name of an environment variable used to configure Photoview type EnvironmentVariable string @@ -30,6 +33,13 @@ const ( EnvSqlitePath EnvironmentVariable = "PHOTOVIEW_SQLITE_PATH" ) +// Feature related +const ( + EnvDisableFaceRecognition EnvironmentVariable = "PHOTOVIEW_DISABLE_FACE_RECOGNITION" + EnvDisableVideoEncoding EnvironmentVariable = "PHOTOVIEW_DISABLE_VIDEO_ENCODING" + EnvDisableRawProcessing EnvironmentVariable = "PHOTOVIEW_DISABLE_RAW_PROCESSING" +) + // GetName returns the name of the environment variable itself func (v EnvironmentVariable) GetName() string { return string(v) @@ -40,6 +50,20 @@ func (v EnvironmentVariable) GetValue() string { return os.Getenv(string(v)) } +// GetBool returns the environment variable as a boolean (defaults to false if not defined) +func (v EnvironmentVariable) GetBool() bool { + value := strings.ToLower(os.Getenv(string(v))) + trueValues := []string{"1", "true"} + + for _, x := range trueValues { + if value == x { + return true + } + } + + return false +} + // ShouldServeUI whether or not the "serve ui" option is enabled func ShouldServeUI() bool { return EnvServeUI.GetValue() == "1" diff --git a/ui/src/components/layout/MainMenu.tsx b/ui/src/components/layout/MainMenu.tsx index b371ef3f..c8967af9 100644 --- a/ui/src/components/layout/MainMenu.tsx +++ b/ui/src/components/layout/MainMenu.tsx @@ -3,6 +3,7 @@ import { NavLink } from 'react-router-dom' import { useQuery, gql } from '@apollo/client' import { authToken } from '../../helpers/authentication' import { useTranslation } from 'react-i18next' +import { mapboxEnabledQuery } from '../../__generated__/mapboxEnabledQuery' export const MAPBOX_QUERY = gql` query mapboxEnabledQuery { @@ -10,6 +11,14 @@ export const MAPBOX_QUERY = gql` } ` +export const FACE_DETECTION_ENABLED_QUERY = gql` + query faceDetectionEnabled { + siteInfo { + faceDetectionEnabled + } + } +` + type MenuButtonProps = { to: string exact: boolean @@ -56,9 +65,16 @@ const MenuSeparator = () => ( export const MainMenu = () => { const { t } = useTranslation() - const mapboxQuery = authToken() ? useQuery(MAPBOX_QUERY) : null + const mapboxQuery = authToken() + ? useQuery(MAPBOX_QUERY) + : null + const faceDetectionEnabledQuery = authToken() + ? useQuery(FACE_DETECTION_ENABLED_QUERY) + : null const mapboxEnabled = !!mapboxQuery?.data?.mapboxToken + const faceDetectionEnabled = + !!faceDetectionEnabledQuery?.data?.siteInfo?.faceDetectionEnabled return (
@@ -104,19 +120,21 @@ export const MainMenu = () => { } /> ) : null} - - - - } - /> + {faceDetectionEnabled ? ( + + + + } + /> + ) : null} { } } -// From https://exiftool.org/TagNames/EXIF.html -// const orientation = { -// 1: 'Horizontal (normal)', -// 2: 'Mirror horizontal', -// 3: 'Rotate 180', -// 4: 'Mirror vertical', -// 5: 'Mirror horizontal and rotate 270 CW', -// 6: 'Rotate 90 CW', -// 7: 'Mirror horizontal and rotate 90 CW', -// 8: 'Rotate 270 CW', -// } - type SidebarContentProps = { media: MediaSidebarMedia hidePreview?: boolean diff --git a/ui/src/extractedTranslations/da/translation.json b/ui/src/extractedTranslations/da/translation.json index 2ad4b2d1..045afbe6 100644 --- a/ui/src/extractedTranslations/da/translation.json +++ b/ui/src/extractedTranslations/da/translation.json @@ -107,7 +107,7 @@ } }, "photos_page": { - "title": "Billeder" + "title": "Tidslinje" }, "places_page": { "title": "Kort" @@ -285,13 +285,13 @@ "sidemenu": { "albums": "Albums", "people": "Personer", - "photos": "Billeder", + "photos": "Tidslinje", "places": "Kort", "settings": "Indstillinger" }, "title": { "loading_album": "Loader album", - "login": "Logind", + "login": "Log ind", "people": "Personer", "settings": "Indstillinger" } diff --git a/ui/src/extractedTranslations/en/translation.json b/ui/src/extractedTranslations/en/translation.json index 42cb1d4a..5426dfa6 100644 --- a/ui/src/extractedTranslations/en/translation.json +++ b/ui/src/extractedTranslations/en/translation.json @@ -107,7 +107,7 @@ } }, "photos_page": { - "title": "Photos" + "title": "Timeline" }, "places_page": { "title": "Places" @@ -285,7 +285,7 @@ "sidemenu": { "albums": "Albums", "people": "People", - "photos": "Photos", + "photos": "Timeline", "places": "Places", "settings": "Settings" },