People: Improve logging, command help, and handling of broken files #22

This commit is contained in:
Michael Mayer 2021-10-05 18:42:39 +02:00
parent 00ced8137b
commit f7153cdd21
43 changed files with 873 additions and 722 deletions

File diff suppressed because it is too large Load diff

View file

@ -41,13 +41,13 @@
@click.stop.prevent="downloadFile(file)">
<translate>Download</translate>
</v-btn>
<v-btn v-if="features.edit && file.Type === 'jpg' && !file.Primary" small depressed dark
<v-btn v-if="features.edit && file.Type === 'jpg' && file.Error === '' && !file.Primary" small depressed dark
color="primary-button"
class="ma-0 action-primary"
@click.stop.prevent="primaryFile(file)">
<translate>Primary</translate>
</v-btn>
<v-btn v-if="features.edit && !file.Sidecar && !file.Primary && file.Root === '/'" small
<v-btn v-if="features.edit && !file.Sidecar && file.Error === '' && !file.Primary && file.Root === '/'" small
depressed dark color="primary-button"
class="ma-0 action-unstack"
@click.stop.prevent="unstackFile(file)">
@ -66,6 +66,12 @@
</td>
<td>{{ file.UID | uppercase }}</td>
</tr>
<tr v-if="file.Error">
<td>
<translate>Error</translate>
</td>
<td><span class="body-2">{{ file.Error | uppercase }}</span></td>
</tr>
<tr v-if="file.InstanceID" title="XMP">
<td>
<translate>Instance ID</translate>
@ -162,12 +168,6 @@
</td>
<td>{{ file.Chroma }} / 100</td>
</tr>
<tr v-if="file.Error">
<td>
<translate>Error</translate>
</td>
<td>{{ file.Error }}</td>
</tr>
<tr v-if="file.Missing">
<td>
<translate>Missing</translate>

View file

@ -76,7 +76,7 @@ func AlbumCover(router *gin.RouterGroup) {
f, err := query.AlbumCoverByUID(uid)
if err != nil {
log.Debugf("%s: no photos yet, using generic image for %s", albumCover, uid)
log.Debugf("%s: %s contains no photos, using generic cover", albumCover, uid)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
@ -84,7 +84,7 @@ func AlbumCover(router *gin.RouterGroup) {
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("%s: could not find original for %s", albumCover, fileName)
log.Errorf("%s: found no original for %s", albumCover, fileName)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.

View file

@ -15,7 +15,7 @@ func TestAlbumCover(t *testing.T) {
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("album has no photo (because is not existing)", func(t *testing.T) {
t.Run("album contains no photos (because is not existing)", func(t *testing.T) {
app, router, conf := NewApiTest()
AlbumCover(router)
r := PerformRequest(app, "GET", "/api/v1/albums/987-986435/t/"+conf.PreviewToken()+"/tile_500")

View file

@ -85,7 +85,7 @@ func FolderCover(router *gin.RouterGroup) {
f, err := query.FolderCoverByUID(uid)
if err != nil {
log.Debugf("%s: no photos yet, using generic image for %s", folderCover, uid)
log.Debugf("%s: %s contains no photos, using generic cover", folderCover, uid)
c.Data(http.StatusOK, "image/svg+xml", folderIconSvg)
return
}

View file

@ -77,7 +77,7 @@ func StartImport(router *gin.RouterGroup) {
}
if len(f.Albums) > 0 {
log.Debugf("import: files will be added to album %s", strings.Join(f.Albums, " and "))
log.Debugf("import: adding files to album %s", strings.Join(f.Albums, " and "))
opt.Albums = f.Albums
}

View file

@ -81,11 +81,11 @@ func PhotoUnstack(router *gin.RouterGroup) {
AbortEntityNotFound(c)
return
} else if related.Len() == 0 {
log.Errorf("photo: no files found for %s (unstack)", txt.Quote(baseName))
log.Errorf("photo: found no files for %s (unstack)", txt.Quote(baseName))
AbortEntityNotFound(c)
return
} else if related.Main == nil {
log.Errorf("photo: no main file found for %s (unstack)", txt.Quote(baseName))
log.Errorf("photo: found no main file for %s (unstack)", txt.Quote(baseName))
AbortEntityNotFound(c)
return
}

View file

@ -85,9 +85,9 @@ func configAction(ctx *cli.Context) error {
fmt.Printf("%-25s %s\n", "site-url", conf.SiteUrl())
fmt.Printf("%-25s %s\n", "site-preview", conf.SitePreview())
fmt.Printf("%-25s %s\n", "site-title", conf.SiteTitle())
fmt.Printf("%-25s %s\n", "site-author", conf.SiteAuthor())
fmt.Printf("%-25s %s\n", "site-caption", conf.SiteCaption())
fmt.Printf("%-25s %s\n", "site-description", conf.SiteDescription())
fmt.Printf("%-25s %s\n", "site-author", conf.SiteAuthor())
fmt.Printf("%-25s %s\n", "cdn-url", conf.CdnUrl("/"))
fmt.Printf("%-25s %s\n", "content-uri", conf.ContentUri())
fmt.Printf("%-25s %s\n", "static-uri", conf.StaticUri())

View file

@ -35,7 +35,7 @@ var FacesCommand = cli.Command{
Flags: []cli.Flag{
cli.BoolFlag{
Name: "fix, f",
Usage: "issues will be fixed automatically",
Usage: "automatically fixes issues",
},
},
Action: facesAuditAction,
@ -46,7 +46,7 @@ var FacesCommand = cli.Command{
Flags: []cli.Flag{
cli.BoolFlag{
Name: "force, f",
Usage: "remove all people and faces",
Usage: "removes all people and faces",
},
},
Action: facesResetAction,
@ -63,7 +63,7 @@ var FacesCommand = cli.Command{
Flags: []cli.Flag{
cli.BoolFlag{
Name: "force, f",
Usage: "update all faces",
Usage: "updates all faces",
},
},
Action: facesUpdateAction,

View file

@ -48,9 +48,7 @@ func TestIndexCommand(t *testing.T) {
// Expected index command output.
assert.Contains(t, output, "indexing originals")
assert.Contains(t, output, "classify: loading labels")
assert.Contains(t, output, "index: no .ppignore file found")
assert.Contains(t, output, "index: updating primary files")
assert.Contains(t, output, "index: flagging hidden files")
assert.Contains(t, output, "index: found no .ppignore file")
} else {
t.Fatal("log output missing")
}

View file

@ -63,7 +63,7 @@ func resetAction(ctx *cli.Context) error {
}
removeSidecarJsonPrompt := promptui.Prompt{
Label: "Permanently delete all *.json photo sidecar files?",
Label: "Permanently remove all JSON photo sidecar files?",
IsConfirm: true,
}
@ -77,7 +77,7 @@ func resetAction(ctx *cli.Context) error {
}
if len(matches) > 0 {
log.Infof("%d json photo sidecar files will be removed", len(matches))
log.Infof("removing %d JSON photo sidecar files", len(matches))
for _, name := range matches {
if err := os.Remove(name); err != nil {
@ -89,16 +89,16 @@ func resetAction(ctx *cli.Context) error {
fmt.Println("")
log.Infof("removed json files [%s]", time.Since(start))
log.Infof("removed JSON sidecar files [%s]", time.Since(start))
} else {
log.Infof("no json files found")
log.Infof("found no JSON sidecar files")
}
} else {
log.Infof("keeping json sidecar files")
log.Infof("keeping JSON sidecar files")
}
removeSidecarYamlPrompt := promptui.Prompt{
Label: "Permanently delete all *.yml photo metadata backups?",
Label: "Permanently remove all YAML photo metadata backup files?",
IsConfirm: true,
}
@ -112,7 +112,7 @@ func resetAction(ctx *cli.Context) error {
}
if len(matches) > 0 {
log.Infof("%d photo metadata backups will be removed", len(matches))
log.Infof("%d YAML photo metadata backup files will be removed", len(matches))
for _, name := range matches {
if err := os.Remove(name); err != nil {
@ -124,16 +124,16 @@ func resetAction(ctx *cli.Context) error {
fmt.Println("")
log.Infof("removed files [%s]", time.Since(start))
log.Infof("removed YAML photo metadata backup files [%s]", time.Since(start))
} else {
log.Infof("no metadata backups found for removal")
log.Infof("found no YAML photo metadata backup files")
}
} else {
log.Infof("keeping backup files")
log.Infof("keeping YAML photo metadata backup files")
}
removeAlbumYamlPrompt := promptui.Prompt{
Label: "Permanently delete all *.yml album backups?",
Label: "Permanently remove all YAML album backup files?",
IsConfirm: true,
}
@ -147,7 +147,7 @@ func resetAction(ctx *cli.Context) error {
}
if len(matches) > 0 {
log.Infof("%d album backups will be removed", len(matches))
log.Infof("%d YAML album backups will be removed", len(matches))
for _, name := range matches {
if err := os.Remove(name); err != nil {
@ -159,12 +159,12 @@ func resetAction(ctx *cli.Context) error {
fmt.Println("")
log.Infof("removed files [%s]", time.Since(start))
log.Infof("removed YAML album backup files [%s]", time.Since(start))
} else {
log.Infof("no album backups found for removal")
log.Infof("found no YAML album backup files")
}
} else {
log.Infof("keeping backup files")
log.Infof("keeping YAML album backup files")
}
conf.Shutdown()

View file

@ -42,6 +42,9 @@ var TotalMem uint64
const ApiUri = "/api/v1"
const StaticUri = "/static"
const DefaultWakeupInterval = int(15 * 60)
const DefaultAutoIndexDelay = int(5 * 60)
const DefaultAutoImportDelay = int(3 * 60)
// Megabyte in bytes.
const Megabyte = 1000 * 1000
@ -328,6 +331,11 @@ func (c *Config) SiteTitle() string {
return c.options.SiteTitle
}
// SiteAuthor returns the site author / copyright.
func (c *Config) SiteAuthor() string {
return c.options.SiteAuthor
}
// SiteCaption returns a short site caption.
func (c *Config) SiteCaption() string {
return c.options.SiteCaption
@ -338,11 +346,6 @@ func (c *Config) SiteDescription() string {
return c.options.SiteDescription
}
// SiteAuthor returns the site author / copyright.
func (c *Config) SiteAuthor() string {
return c.options.SiteAuthor
}
// Debug tests if debug mode is enabled.
func (c *Config) Debug() bool {
return c.options.Debug
@ -372,7 +375,7 @@ func (c *Config) Public() bool {
return c.options.Public
}
// Modify Public state while running. For testing purposes only.
// SetPublic changes authentication while instance is running, for testing purposes only.
func (c *Config) SetPublic(p bool) {
if c.Debug() {
c.options.Public = p
@ -472,7 +475,7 @@ func (c *Config) Workers() int {
// WakeupInterval returns the background worker wakeup interval duration.
func (c *Config) WakeupInterval() time.Duration {
if c.options.WakeupInterval <= 0 || c.options.WakeupInterval > 86400 {
return 15 * time.Minute
return time.Duration(DefaultWakeupInterval) * time.Second
}
return time.Duration(c.options.WakeupInterval) * time.Second
@ -483,7 +486,7 @@ func (c *Config) AutoIndex() time.Duration {
if c.options.AutoIndex < 0 {
return time.Duration(0)
} else if c.options.AutoIndex == 0 || c.options.AutoIndex > 86400 {
return 5 * time.Minute
return time.Duration(DefaultAutoIndexDelay) * time.Second
}
return time.Duration(c.options.AutoIndex) * time.Second
@ -494,13 +497,13 @@ func (c *Config) AutoImport() time.Duration {
if c.options.AutoImport < 0 || c.ReadOnly() {
return time.Duration(0)
} else if c.options.AutoImport == 0 || c.options.AutoImport > 86400 {
return 3 * time.Minute
return time.Duration(DefaultAutoImportDelay) * time.Second
}
return time.Duration(c.options.AutoImport) * time.Second
}
// GeoApi returns the preferred geo coding api (none or places).
// GeoApi returns the preferred geocoding api (none or places).
func (c *Config) GeoApi() string {
if c.options.DisablePlaces {
return ""

View file

@ -11,18 +11,18 @@ import (
var GlobalFlags = []cli.Flag{
cli.BoolFlag{
Name: "debug",
Usage: "run in debug mode, shows additional log messages",
Usage: "enables the debug mode, shows additional log messages",
EnvVar: "PHOTOPRISM_DEBUG",
},
cli.BoolFlag{
Name: "test",
Hidden: true,
Usage: "run in test mode",
Usage: "enables the test mode",
},
cli.BoolFlag{
Name: "demo",
Hidden: true,
Usage: "run in demo mode",
Usage: "enables the demo mode",
EnvVar: "PHOTOPRISM_DEMO",
},
cli.BoolFlag{
@ -33,99 +33,102 @@ var GlobalFlags = []cli.Flag{
},
cli.BoolFlag{
Name: "public, p",
Usage: "no authentication required, disables password protection",
Usage: "disables password authentication",
EnvVar: "PHOTOPRISM_PUBLIC",
},
cli.BoolFlag{
Name: "read-only, r",
Usage: "don't modify originals folder; disables import, upload, and delete",
Usage: "disables import, upload, and delete",
EnvVar: "PHOTOPRISM_READONLY",
},
cli.BoolFlag{
Name: "experimental, e",
Usage: "enable experimental features",
Usage: "enables experimental features",
EnvVar: "PHOTOPRISM_EXPERIMENTAL",
},
cli.StringFlag{
Name: "admin-password",
Usage: "initial admin `PASSWORD`, min 4 characters",
Usage: "sets the initial admin `PASSWORD`, min 4 characters",
EnvVar: "PHOTOPRISM_ADMIN_PASSWORD",
},
cli.StringFlag{
Name: "config-file, c",
Usage: "load initial config options from `FILENAME`",
Usage: "loads config options from `FILENAME`",
EnvVar: "PHOTOPRISM_CONFIG_FILE",
},
cli.StringFlag{
Name: "config-path",
Usage: "config `PATH` containing application settings",
Usage: "sets the config `PATH` for storing settings and other config files",
EnvVar: "PHOTOPRISM_CONFIG_PATH",
},
cli.StringFlag{
Name: "originals-path",
Usage: "originals `PATH` containing your photo and video collection",
Usage: "sets the originals `PATH` containing your photo and video files",
EnvVar: "PHOTOPRISM_ORIGINALS_PATH",
},
cli.IntFlag{
Name: "originals-limit",
Value: 1000,
Usage: "file size limit for originals in `MB`",
Usage: "limits the file size in `MB`",
EnvVar: "PHOTOPRISM_ORIGINALS_LIMIT",
},
cli.StringFlag{
Name: "import-path",
Usage: "optional `PATH` for importing files to originals",
Usage: "sets an optional import `PATH` from which files can be added to originals",
EnvVar: "PHOTOPRISM_IMPORT_PATH",
},
cli.StringFlag{
Name: "storage-path",
Usage: "storage `PATH` for cache, database and sidecar files",
Usage: "sets the storage base `PATH` for cache, database, and sidecar files",
EnvVar: "PHOTOPRISM_STORAGE_PATH",
},
cli.StringFlag{
Name: "sidecar-path",
Usage: "relative or absolute storage `PATH` for sidecar files",
Usage: "sets a custom relative or absolute sidecar `PATH`",
EnvVar: "PHOTOPRISM_SIDECAR_PATH",
},
cli.StringFlag{
Name: "cache-path",
Usage: "cache storage `PATH` for sessions and thumbnails",
Usage: "sets a custom cache `PATH` for storing sessions and thumbnails",
EnvVar: "PHOTOPRISM_CACHE_PATH",
},
cli.StringFlag{
Name: "temp-path",
Usage: "temporary `PATH` for storing uploads and downloads",
Usage: "sets a custom temp `PATH` for storing temporary files",
EnvVar: "PHOTOPRISM_TEMP_PATH",
},
cli.StringFlag{
Name: "backup-path",
Usage: "backup storage `PATH`",
Usage: "sets a custom backup `PATH`",
EnvVar: "PHOTOPRISM_BACKUP_PATH",
},
cli.StringFlag{
Name: "assets-path",
Usage: "assets `PATH` for static resources like models and templates",
Usage: "sets the assets `PATH` containing static resources like icons and templates",
EnvVar: "PHOTOPRISM_ASSETS_PATH",
},
cli.IntFlag{
Name: "workers, w",
Usage: "limits `NUMBER` of indexing workers",
Usage: "limits the `NUMBER` of indexing workers",
EnvVar: "PHOTOPRISM_WORKERS",
Value: cpuid.CPU.PhysicalCores / 2,
},
cli.IntFlag{
Name: "wakeup-interval",
Usage: "background worker wakeup interval in `SECONDS`",
Usage: "adjusts the background worker wakeup interval in `SECONDS`",
Value: DefaultWakeupInterval,
EnvVar: "PHOTOPRISM_WAKEUP_INTERVAL",
},
cli.IntFlag{
Name: "auto-index",
Usage: "auto indexing safety delay in `SECONDS` (WebDAV)",
Usage: "adjusts the WebDAV auto indexing safety delay in `SECONDS` (-1 to disable)",
Value: DefaultAutoIndexDelay,
EnvVar: "PHOTOPRISM_AUTO_INDEX",
},
cli.IntFlag{
Name: "auto-import",
Usage: "auto importing safety delay in `SECONDS` (WebDAV)",
Usage: "adjusts the WebDAV auto import safety delay in `SECONDS` (-1 to disable)",
Value: DefaultAutoImportDelay,
EnvVar: "PHOTOPRISM_AUTO_IMPORT",
},
cli.BoolFlag{
@ -135,12 +138,12 @@ var GlobalFlags = []cli.Flag{
},
cli.BoolFlag{
Name: "disable-webdav",
Usage: "disables built-in WebDAV server",
Usage: "disables the built-in WebDAV server",
EnvVar: "PHOTOPRISM_DISABLE_WEBDAV",
},
cli.BoolFlag{
Name: "disable-settings",
Usage: "disables settings UI and API",
Usage: "disables the settings UI and API",
EnvVar: "PHOTOPRISM_DISABLE_SETTINGS",
},
cli.BoolFlag{
@ -195,83 +198,83 @@ var GlobalFlags = []cli.Flag{
},
cli.BoolFlag{
Name: "detect-nsfw",
Usage: "flag photos as private that may be offensive (requires TensorFlow)",
Usage: "flags photos as private that may be offensive (requires TensorFlow)",
EnvVar: "PHOTOPRISM_DETECT_NSFW",
},
cli.BoolFlag{
Name: "upload-nsfw",
Usage: "allow uploads that may be offensive",
Usage: "allows uploads that may be offensive",
EnvVar: "PHOTOPRISM_UPLOAD_NSFW",
},
cli.StringFlag{
Name: "log-level, l",
Usage: "trace, debug, info, warning, error, fatal or panic",
Usage: "adjusts the log level to trace, debug, info, warning, error, fatal or panic",
Value: "info",
EnvVar: "PHOTOPRISM_LOG_LEVEL",
},
cli.StringFlag{
Name: "log-filename",
Usage: "server log `FILENAME`",
Usage: "sets the server log `FILENAME`",
EnvVar: "PHOTOPRISM_LOG_FILENAME",
Value: "",
},
cli.StringFlag{
Name: "pid-filename",
Usage: "server process id `FILENAME`",
Usage: "sets the server process id `FILENAME`",
EnvVar: "PHOTOPRISM_PID_FILENAME",
},
cli.StringFlag{
Name: "cdn-url",
Usage: "content delivery network `URL` (optional)",
Usage: "sets an optional content delivery network `URL` (optional)",
EnvVar: "PHOTOPRISM_CDN_URL",
},
cli.StringFlag{
Name: "site-url",
Usage: "public site `URL`",
Usage: "sets the public site `URL`",
Value: "http://localhost:2342/",
EnvVar: "PHOTOPRISM_SITE_URL",
},
cli.StringFlag{
Name: "site-preview",
Usage: "public preview image `URL`",
Usage: "sets the public preview image `URL`",
EnvVar: "PHOTOPRISM_SITE_PREVIEW",
},
cli.StringFlag{
Name: "site-title",
Usage: "site title",
Usage: "sets the site title",
Value: "PhotoPrism",
EnvVar: "PHOTOPRISM_SITE_TITLE",
},
cli.StringFlag{
Name: "site-author",
Usage: "sets the artist name or copyright",
EnvVar: "PHOTOPRISM_SITE_AUTHOR",
},
cli.StringFlag{
Name: "site-caption",
Usage: "short site caption",
Usage: "sets the site caption",
Value: "Browse Your Life",
EnvVar: "PHOTOPRISM_SITE_CAPTION",
},
cli.StringFlag{
Name: "site-description",
Usage: "long site description",
Usage: "sets an optional site description",
EnvVar: "PHOTOPRISM_SITE_DESCRIPTION",
},
cli.StringFlag{
Name: "site-author",
Usage: "site artist or copyright",
EnvVar: "PHOTOPRISM_SITE_AUTHOR",
},
cli.IntFlag{
Name: "http-port",
Value: 2342,
Usage: "http server port `NUMBER`",
Usage: "sets the http server port `NUMBER`",
EnvVar: "PHOTOPRISM_HTTP_PORT",
},
cli.StringFlag{
Name: "http-host",
Usage: "http server `IP` address",
Usage: "sets the http server `IP` address",
EnvVar: "PHOTOPRISM_HTTP_HOST",
},
cli.StringFlag{
Name: "http-mode, m",
Usage: "debug, release or test",
Usage: "selects the http server `MODE` (debug, release, or test)",
EnvVar: "PHOTOPRISM_HTTP_MODE",
},
cli.StringFlag{
@ -281,45 +284,45 @@ var GlobalFlags = []cli.Flag{
},
cli.StringFlag{
Name: "database-driver",
Usage: "database driver `NAME` (sqlite or mysql)",
Usage: "selects the database `DRIVER` (sqlite or mysql)",
Value: "sqlite",
EnvVar: "PHOTOPRISM_DATABASE_DRIVER",
},
cli.StringFlag{
Name: "database-dsn",
Usage: "sqlite file name, specifying a `DSN` is optional for mariadb and mysql",
Usage: "sets the sqlite file name, specifying a `DSN` is optional other drivers",
EnvVar: "PHOTOPRISM_DATABASE_DSN",
},
cli.StringFlag{
Name: "database-server",
Usage: "database server `HOST`, specifying a :port is optional",
Usage: "sets the database server `HOST` and port e.g. mysql:3306",
EnvVar: "PHOTOPRISM_DATABASE_SERVER",
},
cli.StringFlag{
Name: "database-name",
Value: "photoprism",
Usage: "database schema `NAME`",
Usage: "sets the database schema `NAME`",
EnvVar: "PHOTOPRISM_DATABASE_NAME",
},
cli.StringFlag{
Name: "database-user",
Value: "photoprism",
Usage: "database user `NAME`",
Usage: "sets the database user `NAME`",
EnvVar: "PHOTOPRISM_DATABASE_USER",
},
cli.StringFlag{
Name: "database-password",
Usage: "database user `PASSWORD`",
Usage: "sets the database user `PASSWORD`",
EnvVar: "PHOTOPRISM_DATABASE_PASSWORD",
},
cli.IntFlag{
Name: "database-conns",
Usage: "`LIMIT` for open database connections",
Usage: "limits the `NUMBER` of open database connections",
EnvVar: "PHOTOPRISM_DATABASE_CONNS",
},
cli.IntFlag{
Name: "database-conns-idle",
Usage: "`LIMIT` for idle database connections",
Usage: "limits the `NUMBER` of idle database connections",
EnvVar: "PHOTOPRISM_DATABASE_CONNS_IDLE",
},
cli.BoolFlag{
@ -329,160 +332,160 @@ var GlobalFlags = []cli.Flag{
},
cli.StringFlag{
Name: "darktable-bin",
Usage: "Darktable CLI `COMMAND` for RAW file conversion",
Usage: "sets the Darktable CLI `COMMAND` for RAW file conversion",
Value: "darktable-cli",
EnvVar: "PHOTOPRISM_DARKTABLE_BIN",
},
cli.StringFlag{
Name: "darktable-blacklist",
Usage: "Comma-separated file extension `BLACKLIST`",
Usage: "disables using Darktable for specific file `EXTENSIONS`",
Value: "raf,cr3,dng",
EnvVar: "PHOTOPRISM_DARKTABLE_BLACKLIST",
},
cli.StringFlag{
Name: "rawtherapee-bin",
Usage: "RawTherapee CLI `COMMAND` for RAW file conversion",
Usage: "sets the RawTherapee CLI `COMMAND` for RAW file conversion",
Value: "rawtherapee-cli",
EnvVar: "PHOTOPRISM_RAWTHERAPEE_BIN",
},
cli.StringFlag{
Name: "rawtherapee-blacklist",
Usage: "Comma-separated file extension `BLACKLIST`",
Usage: "disables using RawTherapee for specific file `EXTENSIONS`",
Value: "",
EnvVar: "PHOTOPRISM_RAWTHERAPEE_BLACKLIST",
},
cli.StringFlag{
Name: "sips-bin",
Usage: "Sips `COMMAND` for RAW file conversion on macOS",
Usage: "sets the Sips `COMMAND` for RAW file conversion on macOS",
Value: "sips",
EnvVar: "PHOTOPRISM_SIPS_BIN",
},
cli.StringFlag{
Name: "heifconvert-bin",
Usage: "HEIC/HEIF image convert `COMMAND`",
Usage: "sets the HEIC/HEIF image convert `COMMAND`",
Value: "heif-convert",
EnvVar: "PHOTOPRISM_HEIFCONVERT_BIN",
},
cli.StringFlag{
Name: "ffmpeg-bin",
Usage: "FFmpeg `COMMAND` for video transcoding and cover images",
Usage: "sets the FFmpeg `COMMAND` for video transcoding and cover images",
Value: "ffmpeg",
EnvVar: "PHOTOPRISM_FFMPEG_BIN",
},
cli.StringFlag{
Name: "ffmpeg-encoder",
Usage: "FFmpeg AVC encoder `NAME`",
Usage: "sets the FFmpeg AVC encoder `NAME`",
Value: "libx264",
EnvVar: "PHOTOPRISM_FFMPEG_ENCODER",
},
cli.IntFlag{
Name: "ffmpeg-bitrate",
Usage: "FFmpeg encoding bitrate `LIMIT` in Mbit/s",
Usage: "limits the FFmpeg encoding `BITRATE` (Mbit/s)",
Value: 50,
EnvVar: "PHOTOPRISM_FFMPEG_BITRATE",
},
cli.IntFlag{
Name: "ffmpeg-buffers",
Usage: "FFmpeg capture buffers",
Usage: "adjusts the number of FFmpeg capture buffers",
Value: 32,
EnvVar: "PHOTOPRISM_FFMPEG_BUFFERS",
},
cli.StringFlag{
Name: "exiftool-bin",
Usage: "ExifTool `COMMAND` for enhanced metadata extraction",
Usage: "sets the ExifTool `COMMAND` for extracting metadata",
Value: "exiftool",
EnvVar: "PHOTOPRISM_EXIFTOOL_BIN",
},
cli.StringFlag{
Name: "download-token",
Usage: "static url `TOKEN` for original file downloads",
Usage: "sets a custom download url `TOKEN`",
EnvVar: "PHOTOPRISM_DOWNLOAD_TOKEN",
},
cli.StringFlag{
Name: "preview-token",
Usage: "static url `TOKEN` for thumbnails and video streaming",
Usage: "sets a custom thumbnail and video streaming url `TOKEN`",
EnvVar: "PHOTOPRISM_PREVIEW_TOKEN",
},
cli.StringFlag{
Name: "thumb-filter",
Usage: "image downscaling `FILTER` (best to worst: blackman, lanczos, cubic, linear)",
Usage: "selects the thumbnail downscaling `FILTER` (best to worst: blackman, lanczos, cubic, linear)",
Value: "lanczos",
EnvVar: "PHOTOPRISM_THUMB_FILTER",
},
cli.IntFlag{
Name: "thumb-size, s",
Usage: "max pre-cached thumbnail size in `PIXELS` (720-7680)",
Usage: "limits the pre-cached thumbnail size in `PIXELS` (720-7680)",
Value: 2048,
EnvVar: "PHOTOPRISM_THUMB_SIZE",
},
cli.BoolFlag{
Name: "thumb-uncached, u",
Usage: "enable on-demand thumbnail generation (high memory and cpu usage)",
Usage: "enables on-demand thumbnail generation (high memory and cpu usage)",
EnvVar: "PHOTOPRISM_THUMB_UNCACHED",
},
cli.IntFlag{
Name: "thumb-size-uncached, x",
Usage: "on-demand thumbnail generation size limit in `PIXELS` (720-7680)",
Usage: "limits the on-demand thumbnail size in `PIXELS` (720-7680)",
Value: 7680,
EnvVar: "PHOTOPRISM_THUMB_SIZE_UNCACHED",
},
cli.IntFlag{
Name: "jpeg-size",
Usage: "size limit for converted image files in `PIXELS` (720-30000)",
Usage: "limits the size of generated JPEG files in `PIXELS` (720-30000)",
Value: 7680,
EnvVar: "PHOTOPRISM_JPEG_SIZE",
},
cli.IntFlag{
Name: "jpeg-quality, q",
Usage: "set to 90+ for high-quality thumbnails (25-100)",
Usage: "adjusts the `QUALITY` of generated JPEG files, a higher value reduces compression (25-100)",
Value: 92,
EnvVar: "PHOTOPRISM_JPEG_QUALITY",
},
cli.IntFlag{
Name: "face-size",
Usage: "face size threshold in `PIXELS`",
Usage: "sets the min face size in `PIXELS`",
Value: face.SizeThreshold,
EnvVar: "PHOTOPRISM_FACE_SIZE",
},
cli.Float64Flag{
Name: "face-score",
Usage: "face `QUALITY` threshold",
Usage: "adjusts the quality `THRESHOLD` for faces",
Value: face.ScoreThreshold,
EnvVar: "PHOTOPRISM_FACE_SCORE",
},
cli.IntFlag{
Name: "face-overlap",
Usage: "face area overlap threshold in `PERCENT`",
Usage: "adjusts the face area overlap threshold in `PERCENT`",
Value: face.OverlapThreshold,
EnvVar: "PHOTOPRISM_FACE_OVERLAP",
},
cli.IntFlag{
Name: "face-cluster-size",
Usage: "size threshold for faces forming a cluster in `PIXELS`",
Usage: "sets the min size of faces forming a cluster in `PIXELS`",
Value: face.ClusterSizeThreshold,
EnvVar: "PHOTOPRISM_FACE_CLUSTER_SIZE",
},
cli.IntFlag{
Name: "face-cluster-score",
Usage: "`QUALITY` threshold for faces forming a cluster",
Usage: "adjusts the quality `THRESHOLD` for faces forming a cluster",
Value: face.ClusterScoreThreshold,
EnvVar: "PHOTOPRISM_FACE_CLUSTER_SCORE",
},
cli.IntFlag{
Name: "face-cluster-core",
Usage: "`NUMBER` of faces forming a cluster core",
Usage: "determines the `NUMBER` of faces forming a cluster core",
Value: face.ClusterCore,
EnvVar: "PHOTOPRISM_FACE_CLUSTER_CORE",
},
cli.Float64Flag{
Name: "face-cluster-dist",
Usage: "similarity `DISTANCE` of faces forming a cluster core",
Usage: "determines how close faces forming a cluster core must be to each other",
Value: face.ClusterDist,
EnvVar: "PHOTOPRISM_FACE_CLUSTER_DIST",
},
cli.Float64Flag{
Name: "face-match-dist",
Usage: "`DISTANCE` offset for matching new faces with clusters",
Usage: "adjusts the similarity offset for matching faces with existing clusters",
Value: face.MatchDist,
EnvVar: "PHOTOPRISM_FACE_MATCH_DIST",
},

View file

@ -78,9 +78,9 @@ type Options struct {
SiteUrl string `yaml:"SiteUrl" json:"SiteUrl" flag:"site-url"`
SitePreview string `yaml:"SitePreview" json:"SitePreview" flag:"site-preview"`
SiteTitle string `yaml:"SiteTitle" json:"SiteTitle" flag:"site-title"`
SiteAuthor string `yaml:"SiteAuthor" json:"SiteAuthor" flag:"site-author"`
SiteCaption string `yaml:"SiteCaption" json:"SiteCaption" flag:"site-caption"`
SiteDescription string `yaml:"SiteDescription" json:"SiteDescription" flag:"site-description"`
SiteAuthor string `yaml:"SiteAuthor" json:"SiteAuthor" flag:"site-author"`
DatabaseDriver string `yaml:"DatabaseDriver" json:"-" flag:"database-driver"`
DatabaseDsn string `yaml:"DatabaseDsn" json:"-" flag:"database-dsn"`
DatabaseServer string `yaml:"DatabaseServer" json:"-" flag:"database-server"`

View file

@ -161,7 +161,7 @@ func (m *Face) ResolveCollision(embeddings face.Embeddings) (resolved bool, err
return false, fmt.Errorf("collision distance must be positive")
} else if dist < 0.02 {
// Ignore if distance is very small as faces may belong to the same person.
log.Infof("faces: %s collision at dist %f reported, same person?", m.ID, dist)
log.Warnf("face %s: clearing ambiguous subject %s, similar face at dist %f with source %s", m.ID, m.SubjUID, dist, SrcString(m.FaceSrc))
// Reset subject UID just in case.
m.SubjUID = ""
@ -182,7 +182,7 @@ func (m *Face) ResolveCollision(embeddings face.Embeddings) (resolved bool, err
if revised, err := m.ReviseMatches(); err != nil {
return true, err
} else if r := len(revised); r > 0 {
log.Infof("faces: revised %d matches after collision", r)
log.Infof("faces: revised %d matches after conflict", r)
}
return true, nil

View file

@ -209,19 +209,19 @@ func (m *File) DeletePermanently() error {
}
if err := UnscopedDb().Delete(Marker{}, "file_uid = ?", m.FileUID).Error; err != nil {
log.Errorf("file: %s (remove markers)", err)
log.Errorf("file %s: %s while removing markers", txt.Quote(m.FileUID), err)
}
if err := UnscopedDb().Delete(FileShare{}, "file_id = ?", m.ID).Error; err != nil {
log.Errorf("file: %s (remove shares)", err)
log.Errorf("file %s: %s while removing share info", txt.Quote(m.FileUID), err)
}
if err := UnscopedDb().Delete(FileSync{}, "file_id = ?", m.ID).Error; err != nil {
log.Errorf("file: %s (remove sync)", err)
log.Errorf("file %s: %s while removing sync info", txt.Quote(m.FileUID), err)
}
if err := m.ReplaceHash(""); err != nil {
log.Errorf("file: %s (remove covers)", err)
log.Errorf("file %s: %s while removing covers", txt.Quote(m.FileUID), err)
}
return UnscopedDb().Delete(m).Error
@ -236,9 +236,9 @@ func (m *File) ReplaceHash(newHash string) error {
// Log values.
if m.FileHash != "" && newHash == "" {
log.Tracef("file: removing hash %s", txt.Quote(m.FileHash))
log.Tracef("file %s: removing hash %s", txt.Quote(m.FileUID), txt.Quote(m.FileHash))
} else if m.FileHash != "" && newHash != "" {
log.Tracef("file: hash %s changed to %s", txt.Quote(m.FileHash), txt.Quote(newHash))
log.Tracef("file %s: hash %s changed to %s", txt.Quote(m.FileUID), txt.Quote(m.FileHash), txt.Quote(newHash))
}
// Set file hash to new value.
@ -314,16 +314,16 @@ func (m *File) AllFilesMissing() bool {
// Create inserts a new row to the database.
func (m *File) Create() error {
if m.PhotoID == 0 {
return fmt.Errorf("file: photo id must not be empty (create)")
return fmt.Errorf("file: can't create file with empty photo id")
}
if err := UnscopedDb().Create(m).Error; err != nil {
log.Errorf("file: %s (create)", err)
log.Errorf("file: %s while saving", err)
return err
}
if _, err := m.SaveMarkers(); err != nil {
log.Errorf("file: %s (create markers for %s)", err, m.FileUID)
log.Errorf("file %s: %s while saving markers", txt.Quote(m.FileUID), err)
return err
}
@ -342,16 +342,16 @@ func (m *File) ResolvePrimary() error {
// Save stores the file in the database.
func (m *File) Save() error {
if m.PhotoID == 0 {
return fmt.Errorf("file: photo id must not be empty (save %s)", m.FileUID)
return fmt.Errorf("file %s: can't save file with empty photo id", m.FileUID)
}
if err := UnscopedDb().Save(m).Error; err != nil {
log.Errorf("file: %s (save %s)", err, m.FileUID)
log.Errorf("file %s: %s while saving", txt.Quote(m.FileUID), err)
return err
}
if _, err := m.SaveMarkers(); err != nil {
log.Errorf("file: %s (save markers for %s)", err, m.FileUID)
log.Errorf("file %s: %s while saving markers", txt.Quote(m.FileUID), err)
return err
}
@ -381,7 +381,7 @@ func (m *File) Updates(values interface{}) error {
// Rename updates the name and path of this file.
func (m *File) Rename(fileName, rootName, filePath, fileBase string) error {
log.Debugf("file: renaming %s to %s", txt.Quote(m.FileName), txt.Quote(fileName))
log.Debugf("file %s: renaming %s to %s", txt.Quote(m.FileUID), txt.Quote(m.FileName), txt.Quote(fileName))
// Update database row.
if err := m.Updates(map[string]interface{}{
@ -425,7 +425,7 @@ func (m *File) Undelete() error {
return err
}
log.Debugf("file: removed missing flag from %s", txt.Quote(m.FileName))
log.Debugf("file %s: removed missing flag from %s", txt.Quote(m.FileUID), txt.Quote(m.FileName))
m.FileMissing = false
m.DeletedAt = nil
@ -544,7 +544,7 @@ func (m *File) Markers() *Markers {
} else if m.FileUID == "" {
m.markers = &Markers{}
} else if res, err := FindMarkers(m.FileUID); err != nil {
log.Warnf("file: %s (load markers)", err)
log.Warnf("file %s: %s while loading markers", txt.Quote(m.FileUID), err)
m.markers = &Markers{}
} else {
m.markers = &res

View file

@ -188,7 +188,7 @@ func TestFile_Save(t *testing.T) {
t.Fatalf("file id should be 0: %d", file.ID)
}
assert.Equal(t, "file: photo id must not be empty (save 123)", err.Error())
assert.Equal(t, "file 123: can't save file with empty photo id", err.Error())
})
t.Run("success", func(t *testing.T) {
photo := &Photo{TakenAtLocal: time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), PhotoTitle: "Berlin / Morning Mood"}

View file

@ -4,8 +4,11 @@ import (
"encoding/json"
"fmt"
"math"
"strings"
"time"
"github.com/dustin/go-humanize/english"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/crop"
@ -69,7 +72,7 @@ func (m *Marker) BeforeCreate(scope *gorm.Scope) error {
// NewMarker creates a new entity.
func NewMarker(file File, area crop.Area, subjUID, markerSrc, markerType string, size, score int) *Marker {
if file.FileHash == "" {
log.Errorf("marker: file hash is empty - you might have found a bug")
log.Errorf("markers: file hash is empty - you might have found a bug")
return nil
}
@ -116,6 +119,28 @@ func (m *Marker) SetEmbeddings(e face.Embeddings) {
m.EmbeddingsJSON = e.JSON()
}
// UpdateFile sets the file uid and thumb and updates the index if the marker already exists.
func (m *Marker) UpdateFile(file *File) (updated bool) {
if file.FileUID != "" && m.FileUID != file.FileUID {
m.FileUID = file.FileUID
updated = true
}
if file.FileHash != "" && !strings.HasPrefix(m.Thumb, file.FileHash) {
m.Thumb = crop.NewArea("crop", m.X, m.Y, m.W, m.H).Thumb(file.FileHash)
updated = true
}
if !updated || m.MarkerUID == "" {
return false
} else if res := UnscopedDb().Model(m).UpdateColumns(Values{"file_uid": m.FileUID, "thumb": m.Thumb}); res.Error != nil {
log.Errorf("marker %s: %s (set file)", m.MarkerUID, res.Error)
return false
} else {
return true
}
}
// Updates multiple columns in the database.
func (m *Marker) Updates(values interface{}) error {
return UnscopedDb().Model(m).Updates(values).Error
@ -207,7 +232,7 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
} else if reported, err := f.ResolveCollision(m.Embeddings()); err != nil {
return false, err
} else if reported {
log.Infof("marker: collision reported for %s, face %s, source %s, subject %s <> %s", m.MarkerUID, f.ID, m.SubjSrc, m.SubjUID, f.SubjUID)
log.Warnf("marker %s: face %s has ambiguous subjects %s <> %s, subject source %s", txt.Quote(m.MarkerUID), txt.Quote(f.ID), txt.Quote(m.SubjUID), txt.Quote(f.SubjUID), SrcString(m.SubjSrc))
return false, nil
} else {
return false, nil
@ -329,17 +354,37 @@ func (m *Marker) SyncSubject(updateRelated bool) (err error) {
UpdateColumns(Values{"subj_uid": m.SubjUID, "subj_src": SrcAuto, "marker_review": false}).Error; err != nil {
return fmt.Errorf("%s (update related markers)", err)
} else if res.RowsAffected > 0 && m.face != nil {
log.Debugf("marker: matched %s with %s", subj.SubjName, m.FaceID)
log.Debugf("markers: matched %s with %s", subj.SubjName, m.FaceID)
return m.face.RefreshPhotos()
}
return nil
}
// InvalidArea tests if the marker area is invalid or out of range.
func (m *Marker) InvalidArea() error {
if m.MarkerType != MarkerFace {
return nil
}
// Ok?
if false == (m.X > 1 || m.Y > 1 || m.X < 0 || m.Y < 0 || m.W <= 0 || m.H <= 0 || m.W > 1 || m.H > 1) {
return nil
}
msg := fmt.Sprintf("invalid %s area x=%d%% y=%d%% w=%d%% h=%d%%", txt.Quote(m.MarkerType), int(m.X*100), int(m.Y*100), int(m.W*100), int(m.H*100))
if m.MarkerUID == "" {
return fmt.Errorf("markers: %s", msg)
} else {
return fmt.Errorf("marker %s: %s", m.MarkerUID, msg)
}
}
// Save updates the existing or inserts a new row.
func (m *Marker) Save() error {
if m.X == 0 || m.Y == 0 || m.X > 1 || m.Y > 1 || m.X < -1 || m.Y < -1 {
return fmt.Errorf("marker: invalid position")
if err := m.InvalidArea(); err != nil {
return err
}
return Db().Save(m).Error
@ -347,8 +392,8 @@ func (m *Marker) Save() error {
// Create inserts a new row to the database.
func (m *Marker) Create() error {
if m.X == 0 || m.Y == 0 || m.X > 1 || m.Y > 1 || m.X < -1 || m.Y < -1 {
return fmt.Errorf("marker: invalid position")
if err := m.InvalidArea(); err != nil {
return err
}
return Db().Create(m).Error
@ -361,7 +406,7 @@ func (m *Marker) Embeddings() face.Embeddings {
} else if len(m.embeddings) > 0 {
return m.embeddings
} else if err := json.Unmarshal(m.EmbeddingsJSON, &m.embeddings); err != nil {
log.Errorf("marker: %s while parsing embeddings json", err)
log.Errorf("markers: %s while parsing embeddings json", err)
}
return m.embeddings
@ -389,10 +434,10 @@ func (m *Marker) Subject() (subj *Subject) {
// Create subject?
if m.SubjSrc != SrcAuto && m.MarkerName != "" && m.SubjUID == "" {
if subj = NewSubject(m.MarkerName, SubjPerson, m.SubjSrc); subj == nil {
log.Errorf("marker: invalid subject %s", txt.Quote(m.MarkerName))
log.Errorf("marker %s: invalid subject %s", txt.Quote(m.MarkerUID), txt.Quote(m.MarkerName))
return nil
} else if subj = FirstOrCreateSubject(subj); subj == nil {
log.Debugf("marker: invalid subject %s", txt.Quote(m.MarkerName))
log.Debugf("marker %s: invalid subject %s", txt.Quote(m.MarkerUID), txt.Quote(m.MarkerName))
return nil
} else {
m.subject = subj
@ -418,9 +463,9 @@ func (m *Marker) ClearSubject(src string) error {
// Find and (soft) delete unused subjects.
start := time.Now()
if count, err := DeleteOrphanPeople(); err != nil {
log.Errorf("marker: %s while removing unused subjects [%s]", err, time.Since(start))
log.Errorf("marker %s: %s while removing unused subjects [%s]", txt.Quote(m.MarkerUID), err, time.Since(start))
} else if count > 0 {
log.Debugf("marker: removed %d people [%s]", count, time.Since(start))
log.Debugf("marker %s: removed %s [%s]", txt.Quote(m.MarkerUID), english.Plural(count, "person", "people"), time.Since(start))
}
}()
@ -433,7 +478,7 @@ func (m *Marker) ClearSubject(src string) error {
} else if resolved, err := m.face.ResolveCollision(m.Embeddings()); err != nil {
return err
} else if resolved {
log.Debugf("marker: resolved collision with face %s", m.face.ID)
log.Debugf("marker %s: resolved ambiguous subjects for face %s", txt.Quote(m.MarkerUID), txt.Quote(m.face.ID))
}
// Clear references.
@ -446,7 +491,7 @@ func (m *Marker) ClearSubject(src string) error {
// Face returns a matching face entity if possible.
func (m *Marker) Face() (f *Face) {
if m.MarkerUID == "" {
log.Debugf("marker: empty uid while finding face")
log.Debugf("markers: can't find face when uid is empty")
return nil
}
@ -459,19 +504,19 @@ func (m *Marker) Face() (f *Face) {
// Add face if size
if m.SubjSrc != SrcAuto && m.FaceID == "" {
if m.Size < face.ClusterSizeThreshold || m.Score < face.ClusterScoreThreshold {
log.Debugf("marker: skipped adding face due to low-quality (uid %s, size %d, score %d)", txt.Quote(m.MarkerUID), m.Size, m.Score)
log.Debugf("marker %s: skipped adding face due to low-quality (size %d, score %d)", txt.Quote(m.MarkerUID), m.Size, m.Score)
return nil
} else if emb := m.Embeddings(); emb.Empty() {
log.Warnf("marker: %s has no embeddings", m.MarkerUID)
log.Warnf("marker %s: found no face embeddings", txt.Quote(m.MarkerUID))
return nil
} else if f = NewFace(m.SubjUID, m.SubjSrc, emb); f == nil {
log.Warnf("marker: failed adding face for id %s", m.MarkerUID)
log.Warnf("marker %s: failed assigning face", txt.Quote(m.MarkerUID))
return nil
} else if f = FirstOrCreateFace(f); f == nil {
log.Warnf("marker: failed adding face for id %s", m.MarkerUID)
log.Warnf("marker %s: failed assigning face", txt.Quote(m.MarkerUID))
return nil
} else if err := f.MatchMarkers(Faceless); err != nil {
log.Errorf("marker: %s (match faces)", err)
log.Errorf("marker %s: %s while matching with faces", txt.Quote(m.MarkerUID), err)
}
m.face = f
@ -654,30 +699,28 @@ func FindFaceMarker(faceId string) *Marker {
if err := Db().Where("face_id = ?", faceId).
Where("thumb <> '' AND marker_invalid = 0").
Order("face_dist ASC, q DESC").First(&result).Error; err != nil {
log.Warnf("marker: face %s not found", txt.Quote(faceId))
log.Warnf("markers: found no marker for face %s", txt.Quote(faceId))
return nil
}
return &result
}
// UpdateOrCreateMarker updates a marker in the database or creates a new one if needed.
func UpdateOrCreateMarker(m *Marker) (*Marker, error) {
// CreateMarkerIfNotExists updates a marker in the database or creates a new one if needed.
func CreateMarkerIfNotExists(m *Marker) (*Marker, error) {
result := Marker{}
if m.MarkerUID != "" {
err := m.Save()
log.Debugf("marker: updated existing %s %s for %s", txt.Quote(m.MarkerType), txt.Quote(m.MarkerUID), txt.Quote(m.FileUID))
return m, err
return m, nil
} else if err := Db().Where(`file_uid = ? AND thumb = ? AND marker_type = ?`,
m.FileUID, m.Thumb, m.MarkerType).First(&result).Error; err == nil {
log.Infof("marker: found existing %s %s for %s", txt.Quote(m.MarkerType), txt.Quote(result.MarkerUID), txt.Quote(result.FileUID))
log.Infof("markers: %s marker %s already exists for %s", txt.Quote(m.MarkerType), txt.Quote(result.MarkerUID), txt.Quote(result.FileUID))
return &result, err
} else if err := m.Create(); err != nil {
log.Warnf("marker: %s while creating %s for %s", err, txt.Quote(m.MarkerType), txt.Quote(m.FileUID))
log.Warnf("markers: %s while adding %s", err, txt.Quote(m.MarkerType))
return m, err
} else {
log.Debugf("marker: added %s %s for %s", txt.Quote(m.MarkerType), txt.Quote(m.MarkerUID), txt.Quote(m.FileUID))
log.Debugf("markers: added %s marker %s for %s", txt.Quote(m.MarkerType), txt.Quote(m.MarkerUID), txt.Quote(m.FileUID))
}
return m, nil

View file

@ -16,6 +16,30 @@ var testArea = crop.Area{
H: 0.355556,
}
var invalidArea1 = crop.Area{
Name: "face",
X: -1,
Y: 0.206944,
W: 0.355556,
H: 0.355556,
}
var invalidArea2 = crop.Area{
Name: "face",
X: 0.1,
Y: 0.206944,
W: 0,
H: 0.355556,
}
var invalidArea3 = crop.Area{
Name: "face",
X: 0.1,
Y: -0.206944,
W: 0.1,
H: 0.355556,
}
func TestMarker_TableName(t *testing.T) {
m := &Marker{}
assert.Contains(t, m.TableName(), "markers")
@ -127,7 +151,7 @@ func TestUpdateOrCreateMarker(t *testing.T) {
assert.Equal(t, SrcImage, m.MarkerSrc)
assert.Equal(t, MarkerLabel, m.MarkerType)
m, err := UpdateOrCreateMarker(m)
m, err := CreateMarkerIfNotExists(m)
if err != nil {
t.Fatal(err)
@ -146,7 +170,7 @@ func TestUpdateOrCreateMarker(t *testing.T) {
func TestMarker_Updates(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel, 100, 65)
m, err := UpdateOrCreateMarker(m)
m, err := CreateMarkerIfNotExists(m)
if err != nil {
t.Fatal(err)
@ -171,7 +195,7 @@ func TestMarker_Updates(t *testing.T) {
func TestMarker_Update(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel, 100, 65)
m, err := UpdateOrCreateMarker(m)
m, err := CreateMarkerIfNotExists(m)
if err != nil {
t.Fatal(err)
@ -192,11 +216,40 @@ func TestMarker_Update(t *testing.T) {
})
}
func TestMarker_InvalidArea(t *testing.T) {
t.Run("TestArea", func(t *testing.T) {
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c4", SrcImage, MarkerFace, 100, 65)
assert.Nil(t, m.InvalidArea())
m.MarkerType = MarkerUnknown
assert.Nil(t, m.InvalidArea())
})
t.Run("InvalidArea1", func(t *testing.T) {
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), invalidArea1, "lt9k3pw1wowuy3c4", SrcImage, MarkerFace, 100, 65)
assert.EqualError(t, m.InvalidArea(), "markers: invalid face area x=-100% y=20% w=35% h=35%")
m.MarkerUID = "m345634636"
assert.EqualError(t, m.InvalidArea(), "marker m345634636: invalid face area x=-100% y=20% w=35% h=35%")
m.MarkerType = MarkerUnknown
assert.Nil(t, m.InvalidArea())
})
t.Run("InvalidArea2", func(t *testing.T) {
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), invalidArea2, "lt9k3pw1wowuy3c4", SrcImage, MarkerFace, 100, 65)
assert.Error(t, m.InvalidArea())
m.MarkerType = MarkerUnknown
assert.Nil(t, m.InvalidArea())
})
t.Run("InvalidArea3", func(t *testing.T) {
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), invalidArea3, "lt9k3pw1wowuy3c4", SrcImage, MarkerFace, 100, 65)
assert.Error(t, m.InvalidArea())
m.MarkerType = MarkerUnknown
assert.Nil(t, m.InvalidArea())
})
}
func TestMarker_Save(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel, 100, 65)
m, err := UpdateOrCreateMarker(m)
m, err := CreateMarkerIfNotExists(m)
if err != nil {
t.Fatal(err)
@ -229,9 +282,14 @@ func TestMarker_Save(t *testing.T) {
assert.NotEmpty(t, p.Files)
})
t.Run("invalid position", func(t *testing.T) {
m := Marker{X: 0, Y: 0}
err := m.Save()
assert.Equal(t, "marker: invalid position", err.Error())
m := Marker{X: -1, Y: 0, W: 0.2, H: 0.133, MarkerType: MarkerFace}
if err := m.Save(); err == nil {
t.Fatal("error expected")
} else {
assert.Equal(t, "markers: invalid face area x=-100% y=0% w=20% h=13%", err.Error())
}
})
}
@ -357,9 +415,13 @@ func TestMarker_SyncSubject(t *testing.T) {
func TestMarker_Create(t *testing.T) {
t.Run("invalid position", func(t *testing.T) {
m := Marker{X: 0, Y: 0}
m := Marker{X: 0, Y: 0, MarkerType: MarkerFace}
err := m.Create()
assert.Equal(t, "marker: invalid position", err.Error())
if err == nil {
t.Fatal("error expected")
} else {
assert.Equal(t, "markers: invalid face area x=0% y=0% w=0% h=0%", err.Error())
}
})
}

View file

@ -1,6 +1,8 @@
package entity
import (
"fmt"
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/pkg/txt"
@ -11,18 +13,20 @@ type Markers []Marker
// Save stores the markers in the database.
func (m Markers) Save(file *File) (count int, err error) {
for i := range m {
if file != nil {
m[i].FileUID = file.FileUID
}
if _, err := UpdateOrCreateMarker(&m[i]); err != nil {
log.Errorf("markers: %s (save)", err)
}
}
if file == nil {
return len(m), nil
return 0, fmt.Errorf("file required for saving markers")
}
for i := range m {
if m[i].UpdateFile(file) {
continue
}
if created, err := CreateMarkerIfNotExists(&m[i]); err != nil {
log.Errorf("markers: %s (save)", err)
} else {
m[i] = *created
}
}
return file.UpdatePhotoFaceCount()

View file

@ -140,7 +140,7 @@ func SavePhotoForm(model Photo, form form.Photo) error {
}
if !model.HasID() {
return errors.New("photo: can't save form, id is empty")
return errors.New("can't save form when photo id is missing")
}
model.UpdateDateFields()
@ -167,7 +167,7 @@ func SavePhotoForm(model Photo, form form.Photo) error {
}
if err := model.SyncKeywordLabels(); err != nil {
log.Errorf("photo: %s", err)
log.Errorf("photo %s: %s while syncing keywords and labels", model.PhotoUID, err)
}
if err := model.UpdateTitle(model.ClassifyLabels()); err != nil {
@ -175,7 +175,7 @@ func SavePhotoForm(model Photo, form form.Photo) error {
}
if err := model.IndexKeywords(); err != nil {
log.Errorf("photo: %s", err.Error())
log.Errorf("photo %s: %s while indexing keywords", model.PhotoUID, err.Error())
}
edited := TimeStamp()
@ -825,7 +825,7 @@ func (m *Photo) SetCoordinates(lat, lng float32, altitude int, source string) {
// SetCamera updates the camera.
func (m *Photo) SetCamera(camera *Camera, source string) {
if camera == nil {
log.Warnf("photo: failed updating camera from source '%s'", source)
log.Warnf("photo %s: failed updating camera from source %s", txt.Quote(m.PhotoUID), SrcString(source))
return
}
@ -845,7 +845,7 @@ func (m *Photo) SetCamera(camera *Camera, source string) {
// SetLens updates the lens.
func (m *Photo) SetLens(lens *Lens, source string) {
if lens == nil {
log.Warnf("photo: failed updating lens from source '%s'", source)
log.Warnf("photo %s: failed updating lens from source %s", txt.Quote(m.PhotoUID), SrcString(source))
return
}
@ -1073,12 +1073,12 @@ func (m *Photo) SetPrimary(fileUID string) error {
if fileUID != "" {
// Do nothing.
} else if err := Db().Model(File{}).
Where("photo_uid = ? AND file_missing = 0 AND file_type = 'jpg'", m.PhotoUID).
Where("photo_uid = ? AND file_type = 'jpg' AND file_missing = 0 AND file_error = ''", m.PhotoUID).
Order("file_width DESC").Limit(1).
Pluck("file_uid", &files).Error; err != nil {
return err
} else if len(files) == 0 {
return fmt.Errorf("%s has no jpeg", m.PhotoUID)
return fmt.Errorf("found no valid jpeg for %s", m.PhotoUID)
} else {
fileUID = files[0]
}

View file

@ -4,6 +4,9 @@ import (
"fmt"
"path/filepath"
"strings"
"time"
"github.com/dustin/go-humanize/english"
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/pkg/fs"
@ -39,12 +42,13 @@ func (m *Photo) SetTitle(title, source string) {
// UpdateTitle updated the photo title based on location and labels.
func (m *Photo) UpdateTitle(labels classify.Labels) error {
if m.TitleSrc != SrcAuto && m.HasTitle() {
return fmt.Errorf("photo: won't update title, %s was modified", m.PhotoUID)
return fmt.Errorf("photo %s: keeping %s title %s", txt.Quote(m.PhotoUID), SrcString(m.TitleSrc), txt.Quote(m.PhotoTitle))
}
var names string
var knownLocation bool
start := time.Now()
oldTitle := m.PhotoTitle
fileTitle := m.FileTitle()
@ -62,7 +66,7 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error {
// TODO: User defined title format
if names != "" {
log.Debugf("photo: using %s to create title for %s", txt.Quote(names), m.PhotoUID)
log.Debugf("photo %s: generating title from %s (%s)", txt.Quote(m.PhotoUID), english.Plural(len(people), "person", "people"), txt.Quote(names))
if l := len([]rune(names)); l > 35 {
m.SetTitle(names, SrcAuto)
@ -76,7 +80,7 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error {
m.SetTitle(fmt.Sprintf("%s / %s / %s", names, loc.City(), m.TakenAt.Format("2006")), SrcAuto)
}
} else if title := labels.Title(loc.Name()); title != "" {
log.Debugf("photo: using label %s to create title for %s", txt.Quote(title), m.PhotoUID)
log.Debugf("photo %s: generating title from label %s", txt.Quote(m.PhotoUID), txt.Quote(title))
if loc.NoCity() || loc.LongCity() || loc.CityContains(title) {
m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), loc.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
} else {
@ -101,7 +105,7 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error {
knownLocation = true
if names != "" {
log.Debugf("photo: using %s to create title for %s", txt.Quote(names), m.PhotoUID)
log.Debugf("photo %s: generating title from %s (%s)", txt.Quote(m.PhotoUID), english.Plural(len(people), "person", "people"), txt.Quote(names))
if l := len([]rune(names)); l > 35 {
m.SetTitle(names, SrcAuto)
@ -115,7 +119,7 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error {
m.SetTitle(fmt.Sprintf("%s / %s / %s", names, m.Place.City(), m.TakenAt.Format("2006")), SrcAuto)
}
} else if title := labels.Title(fileTitle); title != "" {
log.Debugf("photo: using label %s to create title for %s", txt.Quote(title), m.PhotoUID)
log.Debugf("photo %s: generating title from label %s", txt.Quote(m.PhotoUID), txt.Quote(title))
if m.Place.NoCity() || m.Place.LongCity() || m.Place.CityContains(title) {
m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), m.Place.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
} else {
@ -157,7 +161,7 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error {
}
if m.PhotoTitle != oldTitle {
log.Debugf("photo: changed title of %s to %s", m.PhotoUID, txt.Quote(m.PhotoTitle))
log.Debugf("photo %s: changed title to %s [%s]", txt.Quote(m.PhotoUID), txt.Quote(m.PhotoTitle), time.Since(start))
}
return nil
@ -166,7 +170,7 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error {
// UpdateAndSaveTitle updates the photo title and saves it.
func (m *Photo) UpdateAndSaveTitle() error {
if !m.HasID() {
return fmt.Errorf("photo: can't save to database, id is empty")
return fmt.Errorf("can't save photo whithout id")
}
m.PhotoFaces = m.FaceCount()

View file

@ -101,7 +101,7 @@ func (m *Subject) Delete() error {
subjectMutex.Lock()
defer subjectMutex.Unlock()
log.Infof("subject: deleting %s %s", m.SubjType, txt.Quote(m.SubjName))
log.Infof("subject: deleting %s %s", txt.Quote(m.SubjType), txt.Quote(m.SubjName))
event.EntitiesDeleted("subjects", []string{m.SubjUID})
@ -129,7 +129,7 @@ func (m *Subject) Restore() error {
if m.Deleted() {
m.DeletedAt = nil
log.Infof("subject: restoring %s %s", m.SubjType, txt.Quote(m.SubjName))
log.Infof("subject: restoring %s %s", txt.Quote(m.SubjType), txt.Quote(m.SubjName))
event.EntitiesCreated("subjects", []*Subject{m})
@ -167,7 +167,7 @@ func FirstOrCreateSubject(m *Subject) *Subject {
if found := FindSubjectByName(m.SubjName); found != nil {
return found
} else if createErr := m.Create(); createErr == nil {
log.Infof("subject: added %s %s", m.SubjType, txt.Quote(m.SubjName))
log.Infof("subject: added %s %s", txt.Quote(m.SubjType), txt.Quote(m.SubjName))
event.EntitiesCreated("subjects", []*Subject{m})
@ -257,7 +257,7 @@ func (m *Subject) UpdateName(name string) (*Subject, error) {
if err := m.SetName(name); err != nil {
return m, err
} else if err := m.Updates(Values{"SubjName": m.SubjName, "SubjSlug": m.SubjSlug}); err == nil {
log.Infof("subject: renamed %s %s", m.SubjType, txt.Quote(m.SubjName))
log.Infof("subject: renamed %s %s", txt.Quote(m.SubjType), txt.Quote(m.SubjName))
event.EntitiesUpdated("subjects", []*Subject{m})

View file

@ -136,7 +136,7 @@ func TestSubject_Restore(t *testing.T) {
t.Run("success", func(t *testing.T) {
var deleteTime = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
m := &Subject{DeletedAt: &deleteTime, SubjName: "ToBeRestored"}
m := &Subject{DeletedAt: &deleteTime, SubjType: SubjPerson, SubjName: "ToBeRestored"}
err := m.Save()
if err != nil {
t.Fatal(err)
@ -150,7 +150,7 @@ func TestSubject_Restore(t *testing.T) {
assert.False(t, m.Deleted())
})
t.Run("subject not deleted", func(t *testing.T) {
m := &Subject{DeletedAt: nil, SubjName: "NotDeleted1234"}
m := &Subject{DeletedAt: nil, SubjType: SubjPerson, SubjName: "NotDeleted1234"}
err := m.Restore()
if err != nil {
t.Fatal(err)

View file

@ -4,17 +4,17 @@ import (
"github.com/photoprism/photoprism/internal/crop"
)
var CropSize = crop.Sizes[crop.Tile160]
var MatchDist = 0.46
var ClusterDist = 0.64
var ClusterCore = 4
var ClusterScoreThreshold = 15
var ClusterSizeThreshold = 80
var SampleThreshold = 2 * ClusterCore
var OverlapThreshold = 42
var OverlapThresholdFloor = OverlapThreshold - 1
var ScoreThreshold = 9.0
var SizeThreshold = 40
var CropSize = crop.Sizes[crop.Tile160] // Face image crop size for FaceNet.
var OverlapThreshold = 42 // Face area overlap threshold in percent.
var OverlapThresholdFloor = OverlapThreshold - 1 // Reduced overlap area to avoid rounding inconsistencies.
var ScoreThreshold = 9.0 // Min face score.
var ClusterScoreThreshold = 15 // Min score for faces forming a cluster.
var SizeThreshold = 40 // Min face size in pixels.
var ClusterSizeThreshold = 80 // Min size for faces forming a cluster in pixels.
var ClusterDist = 0.64 // Similarity distance threshold of faces forming a cluster core.
var MatchDist = 0.46 // Distance offset threshold for matching new faces with clusters.
var ClusterCore = 4 // Min number of faces forming a cluster core.
var SampleThreshold = 2 * ClusterCore // Threshold for automatic clustering to start.
// QualityThreshold returns the scale adjusted quality score threshold.
func QualityThreshold(scale int) (score float32) {

View file

@ -39,7 +39,7 @@ func RawExif(fileName string, fileType fs.FileFormat) (rawExif []byte, err error
if err != nil {
if strings.HasPrefix(err.Error(), "no exif header") {
return rawExif, fmt.Errorf("metadata: no exif header in %s (parse jpeg)", logName)
return rawExif, fmt.Errorf("metadata: found no exif header in %s (parse jpeg)", logName)
} else if strings.HasPrefix(err.Error(), "no exif data") {
log.Debugf("metadata: failed parsing %s, starting brute-force search (parse jpeg)", logName)
} else {
@ -62,7 +62,7 @@ func RawExif(fileName string, fileType fs.FileFormat) (rawExif []byte, err error
if err != nil {
if err.Error() == "file does not have EXIF" {
return rawExif, fmt.Errorf("metadata: no exif header in %s (parse png)", logName)
return rawExif, fmt.Errorf("metadata: found no exif header in %s (parse png)", logName)
} else {
log.Warnf("metadata: %s in %s (parse png)", err, logName)
}
@ -82,7 +82,7 @@ func RawExif(fileName string, fileType fs.FileFormat) (rawExif []byte, err error
if err != nil {
if err.Error() == "file does not have EXIF" {
return rawExif, fmt.Errorf("metadata: no exif header in %s (parse heic)", logName)
return rawExif, fmt.Errorf("metadata: found no exif header in %s (parse heic)", logName)
} else {
log.Warnf("metadata: %s in %s (parse heic)", err, logName)
}
@ -102,7 +102,7 @@ func RawExif(fileName string, fileType fs.FileFormat) (rawExif []byte, err error
if err != nil {
if err.Error() == "file does not have EXIF" {
return rawExif, fmt.Errorf("metadata: no exif header in %s (parse tiff)", logName)
return rawExif, fmt.Errorf("metadata: found no exif header in %s (parse tiff)", logName)
} else {
log.Warnf("metadata: %s in %s (parse tiff)", err, logName)
}
@ -115,7 +115,7 @@ func RawExif(fileName string, fileType fs.FileFormat) (rawExif []byte, err error
rawExif, err = exif.SearchFileAndExtractExif(fileName)
if err != nil {
return rawExif, fmt.Errorf("metadata: no exif header in %s (search and extract)", logName)
return rawExif, fmt.Errorf("metadata: found no exif header in %s (search and extract)", logName)
}
}

View file

@ -108,7 +108,7 @@ func TestExif(t *testing.T) {
t.Fatal("err should NOT be nil")
}
assert.Equal(t, "metadata: no exif header in tweethog.png (search and extract)", err.Error())
assert.Equal(t, "metadata: found no exif header in tweethog.png (search and extract)", err.Error())
})
t.Run("iphone_7.heic", func(t *testing.T) {
@ -223,7 +223,7 @@ func TestExif(t *testing.T) {
t.Fatal("err should NOT be nil")
}
assert.Equal(t, "metadata: no exif header in no-exif-data.jpg (search and extract)", err.Error())
assert.Equal(t, "metadata: found no exif header in no-exif-data.jpg (search and extract)", err.Error())
})
t.Run("screenshot.png", func(t *testing.T) {
@ -268,7 +268,7 @@ func TestExif(t *testing.T) {
t.Run("gopher-preview.jpg", func(t *testing.T) {
_, err := Exif("testdata/gopher-preview.jpg", fs.FormatJpeg)
assert.EqualError(t, err, "metadata: no exif header in gopher-preview.jpg (search and extract)")
assert.EqualError(t, err, "metadata: found no exif header in gopher-preview.jpg (search and extract)")
})
t.Run("huawei-gps-error.jpg", func(t *testing.T) {

View file

@ -5,6 +5,8 @@ import (
"runtime/debug"
"time"
"github.com/dustin/go-humanize/english"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/mutex"
@ -60,37 +62,37 @@ func (w *Faces) Start(opt FacesOptions) (err error) {
} else if removed > 0 {
log.Infof("faces: removed %d orphan markers [%s]", removed, time.Since(start))
} else {
log.Debugf("faces: no orphan markers [%s]", time.Since(start))
log.Debugf("faces: found no orphan markers [%s]", time.Since(start))
}
// Repair invalid marker face and subject references.
start = time.Now()
if removed, err := query.FixMarkerReferences(); err != nil {
log.Errorf("faces: %s (fix references)", err)
log.Errorf("markers: %s (fix references)", err)
} else if removed > 0 {
log.Infof("faces: fixed %d marker references [%s]", removed, time.Since(start))
log.Infof("markers: fixed %d references [%s]", removed, time.Since(start))
} else {
log.Debugf("faces: no invalid marker references [%s]", time.Since(start))
log.Debugf("markers: found no invalid references [%s]", time.Since(start))
}
// Create known marker subjects if needed.
start = time.Now()
if affected, err := query.CreateMarkerSubjects(); err != nil {
log.Errorf("faces: %s (create subjects)", err)
log.Errorf("markers: %s (create subjects)", err)
} else if affected > 0 {
log.Infof("faces: added %d known marker subjects [%s]", affected, time.Since(start))
log.Infof("markers: added %d known subjects [%s]", affected, time.Since(start))
} else {
log.Debugf("faces: marker subjects already exist [%s]", time.Since(start))
log.Debugf("markers: found no missing subjects [%s]", time.Since(start))
}
// Resolve collisions of different subject's faces.
start = time.Now()
if c, r, err := query.ResolveFaceCollisions(); err != nil {
log.Errorf("faces: %s (resolve collisions)", err)
log.Errorf("faces: %s (resolve ambiguous subjects)", err)
} else if c > 0 {
log.Infof("faces: resolved %d / %d collisions [%s]", r, c, time.Since(start))
log.Infof("faces: resolved %d / %d ambiguous subjects [%s]", r, c, time.Since(start))
} else {
log.Debugf("faces: no collisions detected [%s]", time.Since(start))
log.Debugf("faces: found no ambiguous subjects [%s]", time.Since(start))
}
// Optimize existing face clusters.
@ -100,7 +102,7 @@ func (w *Faces) Start(opt FacesOptions) (err error) {
} else if res.Merged > 0 {
log.Infof("faces: merged %d clusters [%s]", res.Merged, time.Since(start))
} else {
log.Debugf("faces: no clusters could be merged [%s]", time.Since(start))
log.Debugf("faces: found no clusters to be merged [%s]", time.Since(start))
}
var added entity.Faces
@ -125,9 +127,9 @@ func (w *Faces) Start(opt FacesOptions) (err error) {
// Log face matching results.
if matches.Updated > 0 {
log.Infof("faces: updated %d markers, recognized %d faces, %d unknown [%s]", matches.Updated, matches.Recognized, matches.Unknown, time.Since(start))
log.Infof("faces: updated %s, recognized %s, %d unknown [%s]", english.Plural(int(matches.Updated), "marker", "markers"), english.Plural(int(matches.Recognized), "face", "faces"), matches.Unknown, time.Since(start))
} else {
log.Debugf("faces: updated %d markers, recognized %d faces, %d unknown [%s]", matches.Updated, matches.Recognized, matches.Unknown, time.Since(start))
log.Debugf("faces: updated %s, recognized %s, %d unknown [%s]", english.Plural(int(matches.Updated), "marker", "markers"), english.Plural(int(matches.Recognized), "face", "faces"), matches.Unknown, time.Since(start))
}
// Remove unused people.

View file

@ -74,18 +74,18 @@ func (w *Faces) Audit(fix bool) (err error) {
r := f1.SampleRadius + face.MatchDist
log.Infof("face %s: conflict at dist %f, Ø %f from %d samples, collision Ø %f", f1.ID, dist, r, f1.Samples, f1.CollisionRadius)
log.Infof("face %s: ambiguous subject at dist %f, Ø %f from %d samples, collision Ø %f", f1.ID, dist, r, f1.Samples, f1.CollisionRadius)
if f1.SubjUID != "" {
log.Infof("face %s: subject %s (%s %s)", f1.ID, txt.Quote(subj[f1.SubjUID].SubjName), f1.SubjUID, entity.SrcString(f1.FaceSrc))
} else {
log.Infof("face %s: no subject (%s)", f1.ID, entity.SrcString(f1.FaceSrc))
log.Infof("face %s: has no subject (%s)", f1.ID, entity.SrcString(f1.FaceSrc))
}
if f2.SubjUID != "" {
log.Infof("face %s: subject %s (%s %s)", f2.ID, txt.Quote(subj[f2.SubjUID].SubjName), f2.SubjUID, entity.SrcString(f2.FaceSrc))
} else {
log.Infof("face %s: no subject (%s)", f2.ID, entity.SrcString(f2.FaceSrc))
log.Infof("face %s: has no subject (%s)", f2.ID, entity.SrcString(f2.FaceSrc))
}
if !fix {
@ -93,21 +93,21 @@ func (w *Faces) Audit(fix bool) (err error) {
} else if ok, err := f1.ResolveCollision(face.Embeddings{f2.Embedding()}); err != nil {
log.Errorf("face %s: %s", f1.ID, err)
} else if ok {
log.Infof("face %s: collision has been resolved", f1.ID)
log.Infof("face %s: ambiguous subject has been resolved", f1.ID)
resolved++
} else {
log.Infof("face %s: collision could not be resolved", f1.ID)
log.Infof("face %s: ambiguous subject could not be resolved", f1.ID)
}
}
}
}
if conflicts == 0 {
log.Infof("found no conflicting face clusters")
log.Infof("found no ambiguous subjects")
} else if !fix {
log.Infof("%d conflicting face clusters", conflicts)
log.Infof("%s", english.Plural(conflicts, "ambiguous subject", "ambiguous subjects"))
} else {
log.Infof("%d conflicting face clusters, %d resolved", conflicts, resolved)
log.Infof("%s, %d resolved", english.Plural(conflicts, "ambiguous subject", "ambiguous subjects"), resolved)
}
if markers, err := query.MarkersWithSubjectConflict(); err != nil {

View file

@ -19,16 +19,16 @@ func (w *Faces) Cluster(opt FacesOptions) (added entity.Faces, err error) {
// Skip clustering if index contains no new face markers, and force option isn't set.
if opt.Force {
log.Infof("faces: forced clustering")
log.Infof("faces: enforced clustering")
} else if n := query.CountNewFaceMarkers(face.ClusterSizeThreshold, face.ClusterScoreThreshold); n < opt.SampleThreshold() {
log.Debugf("faces: skipping clustering")
log.Debugf("faces: skipped clustering")
return added, nil
}
// Fetch unclustered face embeddings.
embeddings, err := query.Embeddings(false, true, face.ClusterSizeThreshold, face.ClusterScoreThreshold)
log.Debugf("faces: %d unclustered samples found", len(embeddings))
log.Debugf("faces: found %s", english.Plural(len(embeddings), "unclustered sample", "unclustered samples"))
// Anything that keeps us from doing this?
if err != nil {
@ -75,7 +75,7 @@ func (w *Faces) Cluster(opt FacesOptions) (added entity.Faces, err error) {
log.Errorf("faces: face should not be nil - bug?")
} else if err := f.Create(); err == nil {
added = append(added, *f)
log.Debugf("faces: added cluster %s based on %d samples, radius %f", f.ID, f.Samples, f.SampleRadius)
log.Debugf("faces: added cluster %s based on %s, radius %f", f.ID, english.Plural(f.Samples, "sample", "samples"), f.SampleRadius)
} else if err := f.Updates(entity.Values{"UpdatedAt": entity.TimeStamp()}); err != nil {
log.Errorf("faces: %s", err)
} else {

View file

@ -10,7 +10,7 @@ func (w *Faces) Stats() (err error) {
if embeddings, err := query.Embeddings(true, false, 0, 0); err != nil {
return err
} else if samples := len(embeddings); samples == 0 {
log.Infof("faces: no samples found")
log.Infof("faces: found no samples")
} else {
log.Infof("faces: computing distance of %d samples", samples)
@ -101,7 +101,7 @@ func (w *Faces) Stats() (err error) {
}
if l := len(dist); l == 0 {
log.Infof("faces: analyzed %d clusters, no matches", samples)
log.Infof("faces: analyzed %d clusters, found no matches", samples)
} else {
log.Infof("faces: %d faces match to the same person", l)
}

View file

@ -178,7 +178,7 @@ func ImportWorker(jobs <-chan ImportJob) {
continue
}
} else {
log.Warnf("import: no main file for %s, conversion to jpeg failed?", fs.RelName(destMainFileName, imp.originalsPath()))
log.Warnf("import: found no main file for %s, conversion to jpeg may have failed", fs.RelName(destMainFileName, imp.originalsPath()))
}
for _, f := range related.Files {
@ -210,7 +210,7 @@ func ImportWorker(jobs <-chan ImportJob) {
if res.Indexed() && f.IsJpeg() {
if err := f.ResampleDefault(ind.thumbPath(), false); err != nil {
log.Errorf("import: failed creating thumbs for %s (%s)", txt.Quote(f.BaseName()), err.Error())
log.Errorf("import: failed creating thumbnails for %s (%s)", txt.Quote(f.BaseName()), err.Error())
query.SetFileError(res.FileUID, err.Error())
}
}

View file

@ -9,13 +9,13 @@ import (
"strings"
"sync"
"github.com/photoprism/photoprism/internal/face"
"github.com/karrick/godirwalk"
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/internal/mutex"
"github.com/photoprism/photoprism/internal/nsfw"
"github.com/photoprism/photoprism/pkg/fs"
@ -265,7 +265,7 @@ func (ind *Index) Start(opt IndexOptions) fs.Done {
log.Warnf("index: %s (update counts)", err)
}
} else {
log.Infof("index: no new or modified files")
log.Infof("index: found no new or modified files")
}
runtime.GC()

View file

@ -298,8 +298,9 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
case m.IsJpeg():
// Color information
if p, err := m.Colors(Config().ThumbPath()); err != nil {
log.Errorf("index: %s in %s (detect colors)", err.Error(), logName)
log.Debugf("%s while detecting colors", err.Error())
file.FileError = err.Error()
file.FilePrimary = false
} else {
file.FileMainColor = p.MainColor.Name()
file.FileColors = p.Colors.Hex()
@ -633,9 +634,9 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
details.Keywords = strings.Join(txt.UniqueWords(w), ", ")
if details.Keywords != "" {
log.Tracef("index: set keywords %s for %s", details.Keywords, logName)
log.Tracef("index: using keywords %s for %s", details.Keywords, logName)
} else {
log.Tracef("index: no keywords for %s", logName)
log.Tracef("index: found no keywords for %s", logName)
}
photo.PhotoQuality = photo.QualityScore()

View file

@ -12,7 +12,7 @@ import (
func IndexMain(related *RelatedFiles, ind *Index, opt IndexOptions) (result IndexResult) {
// Skip sidecar files without related media file.
if related.Main == nil {
result.Err = fmt.Errorf("index: no main file found for %s", txt.Quote(related.String()))
result.Err = fmt.Errorf("index: found no main file for %s", txt.Quote(related.String()))
result.Status = IndexFailed
return result
}
@ -45,7 +45,7 @@ func IndexMain(related *RelatedFiles, ind *Index, opt IndexOptions) (result Inde
log.Debugf("index: created %s", txt.Quote(jpegFile.BaseName()))
if err := jpegFile.ResampleDefault(ind.thumbPath(), false); err != nil {
result.Err = fmt.Errorf("index: failed creating thumbs for %s (%s)", txt.Quote(f.BaseName()), err.Error())
result.Err = fmt.Errorf("index: failed creating thumbnails for %s (%s)", txt.Quote(f.BaseName()), err.Error())
result.Status = IndexFailed
return result
@ -59,7 +59,7 @@ func IndexMain(related *RelatedFiles, ind *Index, opt IndexOptions) (result Inde
if result.Indexed() && f.IsJpeg() {
if err := f.ResampleDefault(ind.thumbPath(), false); err != nil {
log.Errorf("index: failed creating thumbs for %s (%s)", txt.Quote(f.BaseName()), err.Error())
log.Errorf("index: failed creating thumbnails for %s (%s)", txt.Quote(f.BaseName()), err.Error())
query.SetFileError(result.FileUID, err.Error())
}
}
@ -126,7 +126,7 @@ func IndexRelated(related RelatedFiles, ind *Index, opt IndexOptions) (result In
log.Debugf("index: created %s", txt.Quote(jpegFile.BaseName()))
if err := jpegFile.ResampleDefault(ind.thumbPath(), false); err != nil {
result.Err = fmt.Errorf("index: failed creating thumbs for %s (%s)", txt.Quote(f.BaseName()), err.Error())
result.Err = fmt.Errorf("index: failed creating thumbnails for %s (%s)", txt.Quote(f.BaseName()), err.Error())
result.Status = IndexFailed
return result
@ -140,7 +140,7 @@ func IndexRelated(related RelatedFiles, ind *Index, opt IndexOptions) (result In
if res.Indexed() && f.IsJpeg() {
if err := f.ResampleDefault(ind.thumbPath(), false); err != nil {
log.Errorf("index: failed creating thumbs for %s (%s)", txt.Quote(f.BaseName()), err.Error())
log.Errorf("index: failed creating thumbnails for %s (%s)", txt.Quote(f.BaseName()), err.Error())
query.SetFileError(res.FileUID, err.Error())
}
}

View file

@ -15,7 +15,7 @@ func (m *MediaFile) Location() (*entity.Cell, error) {
data := m.MetaData()
if data.Lat == 0 && data.Lng == 0 {
return nil, errors.New("media: no latitude and longitude in metadata")
return nil, errors.New("media: found no latitude and longitude")
}
m.location = entity.NewCell(data.Lat, data.Lng)

View file

@ -83,7 +83,7 @@ func TestMediaFile_Location(t *testing.T) {
if _, err := mediaFile.Location(); err == nil {
t.Fatal("mediaFile.Location() should return error")
} else {
assert.Equal(t, "media: no latitude and longitude in metadata", err.Error())
assert.Equal(t, "media: found no latitude and longitude", err.Error())
}
})
t.Run("Random.docx", func(t *testing.T) {
@ -97,7 +97,7 @@ func TestMediaFile_Location(t *testing.T) {
location, err := mediaFile.Location()
assert.Error(t, err, "metadata: no exif header in Random.docx")
assert.Error(t, err, "metadata: found no exif header in Random.docx")
assert.Nil(t, location)
})
}

View file

@ -902,7 +902,7 @@ func (m *MediaFile) Thumbnail(path string, sizeName thumb.Name) (filename string
if err != nil {
err = fmt.Errorf("media: failed creating thumbnail for %s (%s)", txt.Quote(m.BaseName()), err)
log.Error(err)
log.Debug(err)
return "", err
}
@ -928,7 +928,7 @@ func (m *MediaFile) ResampleDefault(thumbPath string, force bool) (err error) {
defer func() {
switch count {
case 0:
log.Debug(capture.Time(start, fmt.Sprintf("media: no new thumbnails created for %s", m.BasePrefix(false))))
log.Debug(capture.Time(start, fmt.Sprintf("media: created no new thumbnails for %s", m.BasePrefix(false))))
default:
log.Info(capture.Time(start, fmt.Sprintf("media: created %s for %s", english.Plural(count, "thumbnail", "thumbnails"), m.BasePrefix(false))))
}
@ -961,7 +961,7 @@ func (m *MediaFile) ResampleDefault(thumbPath string, force bool) (err error) {
img, err := imaging.Open(m.FileName())
if err != nil {
log.Errorf("media: %s in %s", err.Error(), txt.Quote(m.BaseName()))
log.Debugf("media: %s in %s", err.Error(), txt.Quote(m.BaseName()))
return err
}

View file

@ -80,7 +80,7 @@ func (m *MediaFile) MetaData() (result meta.Data) {
// Parse regular JSON sidecar files ("img_1234.json")
if !m.IsSidecar() {
if jsonFiles := fs.FormatJson.FindAll(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false); len(jsonFiles) == 0 {
log.Tracef("metadata: no additional sidecar file found for %s", txt.Quote(filepath.Base(m.FileName())))
log.Tracef("metadata: found no additional sidecar file for %s", txt.Quote(filepath.Base(m.FileName())))
} else {
for _, jsonFile := range jsonFiles {
jsonErr := m.metaData.JSON(jsonFile, m.BaseName())

View file

@ -84,7 +84,7 @@ func CountNewFaceMarkers(size, score int) (n int) {
if err := Db().Where("face_src = ?", entity.SrcAuto).
Order("created_at DESC").Limit(1).Take(&f).Error; err != nil {
log.Debugf("faces: no existing clusters")
log.Debugf("faces: found no existing clusters")
}
q := Db().Model(&entity.Markers{}).
@ -181,27 +181,27 @@ func ResolveFaceCollisions() (conflicts, resolved int, err error) {
r := f1.SampleRadius + face.MatchDist
log.Infof("face %s: conflict at dist %f, Ø %f from %d samples, collision Ø %f", f1.ID, dist, r, f1.Samples, f1.CollisionRadius)
log.Infof("face %s: ambiguous subject at dist %f, Ø %f from %d samples, collision Ø %f", f1.ID, dist, r, f1.Samples, f1.CollisionRadius)
if f1.SubjUID != "" {
log.Debugf("face %s: subject %s (%s %s)", f1.ID, txt.Quote(f1.SubjUID), f1.SubjUID, entity.SrcString(f1.FaceSrc))
} else {
log.Debugf("face %s: no subject (%s)", f1.ID, entity.SrcString(f1.FaceSrc))
log.Debugf("face %s: has no subject (%s)", f1.ID, entity.SrcString(f1.FaceSrc))
}
if f2.SubjUID != "" {
log.Debugf("face %s: subject %s (%s %s)", f2.ID, txt.Quote(f2.SubjUID), f2.SubjUID, entity.SrcString(f2.FaceSrc))
} else {
log.Debugf("face %s: no subject (%s)", f2.ID, entity.SrcString(f2.FaceSrc))
log.Debugf("face %s: has no subject (%s)", f2.ID, entity.SrcString(f2.FaceSrc))
}
if ok, err := f1.ResolveCollision(face.Embeddings{f2.Embedding()}); err != nil {
log.Errorf("face %s: %s", f1.ID, err)
} else if ok {
log.Infof("face %s: collision has been resolved", f1.ID)
log.Infof("face %s: conflict has been resolved", f1.ID)
resolved++
} else {
log.Debugf("face %s: collision could not be resolved", f1.ID)
log.Debugf("face %s: conflict could not be resolved", f1.ID)
}
}
}

View file

@ -3,6 +3,8 @@ package query
import (
"time"
"github.com/dustin/go-humanize/english"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/entity"
)
@ -83,11 +85,19 @@ func PhotosMissing(limit int, offset int) (entities entity.Photos, err error) {
// ResetPhotoQuality sets photo quality scores to -1 if files are missing.
func ResetPhotoQuality() error {
log.Info("index: flagging hidden files")
start := time.Now()
return Db().Table("photos").
Where("id NOT IN (SELECT photo_id FROM files WHERE file_primary = 1 AND file_missing = 0 AND deleted_at IS NULL)").
Update("photo_quality", -1).Error
res := Db().Table("photos").
Where("id NOT IN (SELECT photo_id FROM files WHERE file_primary = 1 AND file_missing = 0 AND file_error = '' AND deleted_at IS NULL)").
Update("photo_quality", -1)
if res.RowsAffected == 0 {
log.Debugf("index: found no additional broken photos [%s]", time.Since(start))
} else {
log.Infof("index: flagged %s as hidden [%s]", english.Plural(int(res.RowsAffected), "broken photo", "broken photos"), time.Since(start))
}
return res.Error
}
// PhotosCheck returns photos selected for maintenance.
@ -124,7 +134,7 @@ func OrphanPhotos() (photos entity.Photos, err error) {
// FixPrimaries tries to set a primary file for photos that have none.
func FixPrimaries() error {
log.Info("index: updating primary files")
start := time.Now()
var photos entity.Photos
@ -136,13 +146,20 @@ func FixPrimaries() error {
return err
}
if len(photos) == 0 {
log.Debugf("index: found no photos without primary file [%s]", time.Since(start))
return nil
}
for _, p := range photos {
log.Debugf("index: finding new primary file for photo %s", p.PhotoUID)
log.Debugf("index: searching primary file for %s", p.PhotoUID)
if err := p.SetPrimary(""); err != nil {
log.Infof("index: %s (set primary)", err)
}
}
log.Infof("index: updated primary files [%s]", time.Since(start))
return nil
}

View file

@ -96,7 +96,7 @@ func FromFile(imageFilename, hash, thumbPath string, width, height, orientation
img, err := imaging.Open(imageFilename)
if err != nil {
log.Errorf("resample: %s in %s", err, txt.Quote(filepath.Base(imageFilename)))
log.Debugf("resample: %s in %s", err, txt.Quote(filepath.Base(imageFilename)))
return "", err
}

View file

@ -122,7 +122,7 @@ func (l *IgnoreList) Dir(dir string) error {
}
if !FileExists(fileName) {
return fmt.Errorf("no %s file found", l.configFile)
return fmt.Errorf("found no %s file", l.configFile)
}
return l.ConfigFile(fileName)