Metadata: Add Camera Make and Model updates via CLI & API #5663 #5656

Mirror the lens make/model editing surface for cameras: entity UpdateMakeModel/SaveForm, form validation, query, search, the GET/PUT /api/v1/cameras endpoints, and the cameras CLI command, plus a cameras ACL resource and scope.

Also tidy the lens surface for parity: self-validating SaveForm, empty make/model guard, X-Count search header, service-role grant, the empty-id/slug docs, and order cameras before lenses everywhere.
This commit is contained in:
Michael Mayer 2026-06-15 09:25:44 +00:00
parent 7c6546ce19
commit bc327016ad
43 changed files with 1354 additions and 22 deletions

81
internal/api/cameras.go Normal file
View file

@ -0,0 +1,81 @@
package api
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/i18n"
)
// UpdateCamera updates camera make and model properties.
//
// PUT /api/v1/cameras/:id
//
// @Summary updates camera name
// @Id UpdateCamera
// @Tags Cameras
// @Accept json
// @Produce json
// @Success 200 {object} entity.Camera
// @Failure 401,403,404,429 {object} i18n.Response
// @Param id path string true "Camera ID"
// @Param camera body form.Camera true "Properties to be updated, only Make and Model supported"
// @Router /api/v1/cameras/{id} [put]
func UpdateCamera(router *gin.RouterGroup) {
router.PUT("/cameras/:id", func(c *gin.Context) {
s := Auth(c, acl.ResourceCameras, acl.ActionUpdate)
if s.Abort(c) {
return
}
// Find camera by ID. A non-numeric id parses to 0 and is rejected as not found below,
// so the parse error is intentionally ignored.
cameraId, _ := strconv.ParseUint(clean.Token(c.Param("id")), 10, 32)
m := query.FindCameraByID(uint(cameraId))
if m == nil {
Abort(c, http.StatusNotFound, i18n.ErrCameraNotFound)
return
}
// Create new camera form.
frm, frmErr := form.NewCamera(m)
if frmErr != nil {
Abort(c, http.StatusBadRequest, i18n.ErrBadRequest)
return
}
// Set form values from request.
LimitRequestBodyBytes(c, MaxMutationRequestBytes)
if frmErr = c.BindJSON(frm); frmErr != nil {
if IsRequestBodyTooLarge(frmErr) {
AbortRequestTooLarge(c, i18n.ErrBadRequest)
return
}
AbortBadRequest(c, frmErr)
return
} else if frmErr = frm.Validate(); frmErr != nil {
AbortInvalidName(c)
return
}
// Save camera and return new model values if successful.
if err := m.SaveForm(frm); err != nil {
log.Errorf("camera: %s", clean.Error(err))
AbortSaveFailed(c)
return
}
c.JSON(http.StatusOK, m)
})
}

View file

@ -0,0 +1,63 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/entity/search"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/txt"
)
// SearchCameras finds and returns cameras as JSON.
//
// @Summary finds and returns cameras as JSON
// @Id SearchCameras
// @Tags Cameras
// @Produce json
// @Success 200 {array} search.Camera
// @Header 200 {number} X-Count "The actual number of cameras returned"
// @Header 200 {number} X-Limit "The limit of the number of cameras to be returned"
// @Header 200 {number} X-Offset "The offset that was used"
// @Failure 401,429,403,400 {object} i18n.Response
// @Param count query int true "maximum number of results" minimum(1) maximum(100000)
// @Param offset query int false "search result offset" minimum(0) maximum(100000)
// @Param nomake query bool false "show where make is blank"
// @Param q query string false "search query"
// @Router /api/v1/cameras [get]
func SearchCameras(router *gin.RouterGroup) {
router.GET("/cameras", func(c *gin.Context) {
s := Auth(c, acl.ResourceCameras, acl.ActionSearch)
if s.Abort(c) {
return
}
var frm form.SearchCameras
err := c.MustBindWith(&frm, binding.Form)
if err != nil {
AbortBadRequest(c, err)
return
}
// Search matching cameras.
result, err := search.Cameras(frm)
if err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())})
return
}
AddCountHeader(c, len(result))
AddLimitHeader(c, frm.Count)
AddOffsetHeader(c, frm.Offset)
AddTokenHeaders(c, s)
c.JSON(http.StatusOK, result)
})
}

View file

@ -0,0 +1,33 @@
package api
import (
"net/http"
"strconv"
"testing"
"github.com/tidwall/gjson"
"github.com/stretchr/testify/assert"
)
func TestSearchCameras(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
SearchCameras(router)
r := PerformRequest(app, "GET", "/api/v1/cameras?count=15")
count := gjson.Get(r.Body.String(), "#")
assert.Greater(t, count.Int(), int64(0))
assert.Equal(t, http.StatusOK, r.Code)
result := r.Result()
xCount, err := strconv.Atoi(result.Header.Get("X-Count"))
assert.NoError(t, err, "strconv for X-Count failed")
// X-Count must equal the number of records in the body.
assert.Equal(t, int(count.Int()), xCount)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
SearchCameras(router)
r := PerformRequest(app, "GET", "/api/v1/cameras?xxx=15")
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}

View file

@ -0,0 +1,55 @@
package api
import (
"net/http"
"testing"
"github.com/tidwall/gjson"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity"
)
func TestUpdateCamera(t *testing.T) {
t.Run("Success", func(t *testing.T) {
defer func() {
entity.FlushCameraCache()
assert.NoError(t, entity.UnscopedDb().Save(entity.CameraFixtures.Pointer("canon-eos-7d")).Error)
}()
app, router, _ := NewApiTest()
UpdateCamera(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/cameras/1000002", `{"Make": "Pentax", "Model": "K-1"}`)
val := gjson.Get(r.Body.String(), "Name")
assert.Equal(t, "PENTAX K-1", val.String())
val2 := gjson.Get(r.Body.String(), "Model")
assert.Equal(t, "K-1", val2.String())
val3 := gjson.Get(r.Body.String(), "Make")
assert.Equal(t, "PENTAX", val3.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateCamera(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/cameras/1000002", `{"Make": 123, "Model": ""}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, "Unable to do that", val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("BadModel", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateCamera(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/cameras/1000002", `{"Make": "123", "Model": ""}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, "Invalid name", val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateCamera(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/cameras/199000002", `{"Make": "Pentax", "Model": "K-1"}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, "Camera not found", val.String())
assert.Equal(t, http.StatusNotFound, r.Code)
})
}

View file

@ -35,8 +35,9 @@ func UpdateLens(router *gin.RouterGroup) {
return
}
// Find lens by ID.
lensId, err := strconv.ParseUint(clean.Token(c.Param("id")), 10, 32)
// Find lens by ID. A non-numeric id parses to 0 and is rejected as not found below,
// so the parse error is intentionally ignored.
lensId, _ := strconv.ParseUint(clean.Token(c.Param("id")), 10, 32)
m := query.FindLensByID(uint(lensId))
if m == nil {
@ -69,7 +70,7 @@ func UpdateLens(router *gin.RouterGroup) {
}
// Save lens and return new model values if successful.
if err = m.SaveForm(frm); err != nil {
if err := m.SaveForm(frm); err != nil {
log.Errorf("lens: %s", clean.Error(err))
AbortSaveFailed(c)
return

View file

@ -19,6 +19,9 @@ import (
// @Tags Lenses
// @Produce json
// @Success 200 {array} search.Lens
// @Header 200 {number} X-Count "The actual number of lenses returned"
// @Header 200 {number} X-Limit "The limit of the number of lenses to be returned"
// @Header 200 {number} X-Offset "The offset that was used"
// @Failure 401,429,403,400 {object} i18n.Response
// @Param count query int true "maximum number of results" minimum(1) maximum(100000)
// @Param offset query int false "search result offset" minimum(0) maximum(100000)
@ -50,7 +53,7 @@ func SearchLenses(router *gin.RouterGroup) {
return
}
// TODO c.Header("X-Count", strconv.Itoa(count))
AddCountHeader(c, len(result))
AddLimitHeader(c, frm.Count)
AddOffsetHeader(c, frm.Offset)
AddTokenHeaders(c, s)

View file

@ -2,6 +2,7 @@ package api
import (
"net/http"
"strconv"
"testing"
"github.com/tidwall/gjson"
@ -15,8 +16,13 @@ func TestSearchLenses(t *testing.T) {
SearchLenses(router)
r := PerformRequest(app, "GET", "/api/v1/lenses?count=15")
count := gjson.Get(r.Body.String(), "#")
assert.LessOrEqual(t, int64(3), count.Int())
assert.Greater(t, count.Int(), int64(0))
assert.Equal(t, http.StatusOK, r.Code)
result := r.Result()
xCount, err := strconv.Atoi(result.Header.Get("X-Count"))
assert.NoError(t, err, "strconv for X-Count failed")
// X-Count must equal the number of records in the body.
assert.Equal(t, int(count.Int()), xCount)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()

View file

@ -920,6 +920,9 @@
"calendar": {
"type": "boolean"
},
"cameras": {
"type": "boolean"
},
"delete": {
"type": "boolean"
},
@ -2675,6 +2678,17 @@
},
"type": "object"
},
"form.Camera": {
"properties": {
"Make": {
"type": "string"
},
"Model": {
"type": "string"
}
},
"type": "object"
},
"form.ChangePassword": {
"properties": {
"new": {
@ -3955,6 +3969,44 @@
},
"type": "object"
},
"search.Camera": {
"properties": {
"CreatedAt": {
"type": "string"
},
"DeletedAt": {
"type": "string"
},
"Description": {
"type": "string"
},
"ID": {
"type": "integer"
},
"Make": {
"type": "string"
},
"Model": {
"type": "string"
},
"Name": {
"type": "string"
},
"Notes": {
"type": "string"
},
"Slug": {
"type": "string"
},
"Type": {
"type": "string"
},
"UpdatedAt": {
"type": "string"
}
},
"type": "object"
},
"search.Face": {
"properties": {
"CollisionRadius": {
@ -6766,6 +6818,163 @@
]
}
},
"/api/v1/cameras": {
"get": {
"operationId": "SearchCameras",
"parameters": [
{
"description": "maximum number of results",
"in": "query",
"maximum": 100000,
"minimum": 1,
"name": "count",
"required": true,
"type": "integer"
},
{
"description": "search result offset",
"in": "query",
"maximum": 100000,
"minimum": 0,
"name": "offset",
"type": "integer"
},
{
"description": "show where make is blank",
"in": "query",
"name": "nomake",
"type": "boolean"
},
{
"description": "search query",
"in": "query",
"name": "q",
"type": "string"
}
],
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK",
"headers": {
"X-Count": {
"description": "The actual number of cameras returned",
"type": "number"
},
"X-Limit": {
"description": "The limit of the number of cameras to be returned",
"type": "number"
},
"X-Offset": {
"description": "The offset that was used",
"type": "number"
}
},
"schema": {
"items": {
"$ref": "#/definitions/search.Camera"
},
"type": "array"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
}
},
"summary": "finds and returns cameras as JSON",
"tags": [
"Cameras"
]
}
},
"/api/v1/cameras/{id}": {
"put": {
"consumes": [
"application/json"
],
"operationId": "UpdateCamera",
"parameters": [
{
"description": "Camera ID",
"in": "path",
"name": "id",
"required": true,
"type": "string"
},
{
"description": "Properties to be updated, only Make and Model supported",
"in": "body",
"name": "camera",
"required": true,
"schema": {
"$ref": "#/definitions/form.Camera"
}
}
],
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/entity.Camera"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
}
},
"summary": "updates camera name",
"tags": [
"Cameras"
]
}
},
"/api/v1/cluster": {
"get": {
"operationId": "ClusterSummary",
@ -8736,6 +8945,20 @@
"responses": {
"200": {
"description": "OK",
"headers": {
"X-Count": {
"description": "The actual number of lenses returned",
"type": "number"
},
"X-Limit": {
"description": "The limit of the number of lenses to be returned",
"type": "number"
},
"X-Offset": {
"description": "The offset that was used",
"type": "number"
}
},
"schema": {
"items": {
"$ref": "#/definitions/search.Lens"

View file

@ -65,6 +65,7 @@ const (
ResourcePeople Resource = "people"
ResourcePlaces Resource = "places"
ResourceLabels Resource = "labels"
ResourceCameras Resource = "cameras"
ResourceLenses Resource = "lenses"
ResourceConfig Resource = "config"
ResourceSettings Resource = "settings"

View file

@ -14,6 +14,7 @@ var ResourceNames = []Resource{
ResourcePeople,
ResourcePlaces,
ResourceLabels,
ResourceCameras,
ResourceLenses,
ResourceConfig,
ResourceSettings,

View file

@ -57,9 +57,15 @@ var Rules = ACL{
RoleAdmin: GrantFullAccess,
RoleClient: GrantFullAccess,
},
ResourceCameras: Roles{
RoleAdmin: GrantFullAccess,
RoleService: GrantFullAccess,
RoleClient: GrantFullAccess,
},
ResourceLenses: Roles{
RoleAdmin: GrantFullAccess,
RoleClient: GrantFullAccess,
RoleAdmin: GrantFullAccess,
RoleService: GrantFullAccess,
RoleClient: GrantFullAccess,
},
ResourceConfig: Roles{
RoleAdmin: GrantFullAccess,

View file

@ -17,7 +17,8 @@ var ScopeDescriptions = map[string]string{
ResourcePeople.String(): "Manage people records and face assignments.",
ResourcePlaces.String(): "Access maps, locations, and place clusters.",
ResourceLabels.String(): "Manage subject labels and keywords.",
ResourceLenses.String(): "Manage camera lense make and model values.",
ResourceCameras.String(): "Manage camera make and model values.",
ResourceLenses.String(): "Manage lens make and model values.",
ResourceConfig.String(): "Read configuration reports and summaries.",
ResourceSettings.String(): "Read and update application settings.",
ResourcePasscode.String(): "Manage app passcodes and guest access codes.",

View file

@ -0,0 +1,143 @@
package commands
import (
"fmt"
"strconv"
"strings"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/entity/search"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/txt/report"
)
// CamerasCommand registers the "cameras" CLI command.
var CamerasCommand = &cli.Command{
Name: "cameras",
Usage: "Camera management subcommands",
Subcommands: []*cli.Command{
CamerasListCommand,
CamerasUpdateCommand,
},
}
// CamerasListCommand registers the list sub command.
var CamerasListCommand = &cli.Command{
Name: "ls",
Usage: "Lists discovered cameras",
ArgsUsage: "[query]",
Flags: append(report.CliFlags, CountFlag, OffsetFlag, NoMakeFlag),
Action: camerasListAction,
}
// CamerasUpdateCommand registers the update sub command.
var CamerasUpdateCommand = &cli.Command{
Name: "update",
Usage: "Updates a specific camera Make and Model",
Flags: []cli.Flag{
&cli.UintFlag{Name: "id", Usage: "camera id", Required: true},
&cli.StringFlag{Name: "make", Usage: "the make of the camera", Required: true},
&cli.StringFlag{Name: "model", Usage: "the model of the camera", Required: true},
},
Action: camerasUpdateAction,
}
// camerasListAction searches the database for cameras.
func camerasListAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
filter := strings.TrimSpace(strings.Join(ctx.Args().Slice(), " "))
// Pagination identical to API defaults.
count := int(ctx.Uint("count")) //nolint:gosec // CLI flag bounded by validation
if count <= 0 || count > 1000 {
count = 100
}
offset := max(ctx.Int("offset"), 0)
frm := form.SearchCameras{
Query: filter,
NoMake: ctx.Bool("nomake"),
Count: count,
Offset: offset,
}
results, err := search.Cameras(frm)
if err != nil {
return err
}
format := report.CliFormat(ctx)
cols := []string{"ID", "Camera Slug", "Camera Name", "Camera Make", "Camera Model", "Updated At"}
rows := make([][]string, 0, len(results))
for _, found := range results {
v := []string{strconv.FormatUint(uint64(found.ID), 10), found.CameraSlug, found.CameraName, found.CameraMake, found.CameraModel, found.UpdatedAt.Format("2006-01-02 15:04:05")}
rows = append(rows, v)
}
result, err := report.RenderFormat(rows, cols, format)
if err != nil {
return err
}
fmt.Println(result)
return nil
})
}
// camerasUpdateAction updates the make and model of a specific camera.
func camerasUpdateAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
cameraId := ctx.Uint("id")
cameraMake := ctx.String("make")
cameraModel := ctx.String("model")
camera := query.FindCameraByID(cameraId)
if camera == nil {
return cli.Exit("camera not found", 1)
}
if err := camera.UpdateMakeModel(cameraMake, cameraModel); err != nil {
return cli.Exit(err, 1)
}
frm := form.SearchCameras{
ID: strconv.FormatUint(uint64(camera.ID), 10),
Count: 10,
Offset: 0,
}
results, err := search.Cameras(frm)
if err != nil {
return err
}
format := report.CliFormat(ctx)
cols := []string{"ID", "Camera Slug", "Camera Name", "Camera Make", "Camera Model", "Updated At"}
rows := make([][]string, 0, len(results))
for _, found := range results {
v := []string{strconv.FormatUint(uint64(found.ID), 10), found.CameraSlug, found.CameraName, found.CameraMake, found.CameraModel, found.UpdatedAt.Format("2006-01-02 15:04:05")}
rows = append(rows, v)
}
result, err := report.RenderFormat(rows, cols, format)
if err != nil {
return err
}
fmt.Println(result)
return nil
})
}

View file

@ -0,0 +1,86 @@
package commands
import (
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/entity"
)
func TestCamerasCommand(t *testing.T) {
t.Run("ListNoOptions", func(t *testing.T) {
// Run command with test context.
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "ls"})
assert.NoError(t, err)
// Check command output for plausibility.
for _, expect := range entity.CameraFixtures {
assert.Contains(t, output, strconv.FormatUint(uint64(expect.ID), 10))
assert.Contains(t, output, expect.CameraSlug)
assert.Contains(t, output, expect.CameraName)
}
})
t.Run("ListWithCount", func(t *testing.T) {
// Run command with test context.
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "ls", "--count=1", "--offset=0"})
assert.NoError(t, err)
// Canon EOS 7D sorts last by make/model/slug, so it must not appear in the first row.
assert.NotEmpty(t, output)
assert.NotContains(t, output, "1000002")
})
t.Run("ListWithNoMake", func(t *testing.T) {
// Run command with test context.
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "ls", "--nomake"})
assert.NoError(t, err)
// Only the unknown camera has a blank make.
assert.Contains(t, output, "zz")
assert.Contains(t, output, "Unknown")
assert.NotContains(t, output, "1000002")
assert.NotContains(t, output, "Canon EOS 7D")
})
t.Run("UpdateWithNoModel", func(t *testing.T) {
// Run command with test context.
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "update", "--id=1000002", "--make=Nikon"})
assert.Error(t, err)
assert.Len(t, output, 0)
assert.Contains(t, err.Error(), `Required flag "model" not set`)
})
t.Run("UpdateWithNoMake", func(t *testing.T) {
// Run command with test context.
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "update", "--id=1000002", `--model=K-1`})
assert.Error(t, err)
assert.Len(t, output, 0)
assert.Contains(t, err.Error(), `Required flag "make" not set`)
})
t.Run("UpdateWithEmptyMakeAndModel", func(t *testing.T) {
// Explicit empty strings satisfy the Required flag check, so the guard in UpdateMakeModel
// must reject them to prevent blanking a camera.
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "update", "--id=1000002", "--make=", "--model="})
assert.Error(t, err)
assert.Len(t, output, 0)
assert.Contains(t, err.Error(), "make and model must not be empty")
var exitErr cli.ExitCoder
if assert.ErrorAs(t, err, &exitErr) {
assert.Equal(t, 1, exitErr.ExitCode())
}
})
t.Run("UpdateValid", func(t *testing.T) {
defer func() {
entity.FlushCameraCache()
assert.NoError(t, entity.Db().Save(entity.CameraFixtures.Pointer("canon-eos-7d")).Error)
}()
// Run command with test context.
output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "update", "--id=1000002", "--make=Pentax", `--model=K-1`})
assert.NoError(t, err)
// Check command output for plausibility.
assert.Contains(t, output, "Updated At")
assert.Contains(t, output, "PENTAX K-1")
assert.Contains(t, output, "1000002")
})
}

View file

@ -61,6 +61,8 @@ var PhotoPrism = []*cli.Command{
DownloadCommand,
VisionCommands,
FacesCommands,
CamerasCommand,
LensesCommand,
PlacesCommands,
PurgeCommand,
CleanUpCommand,
@ -85,7 +87,6 @@ var PhotoPrism = []*cli.Command{
EditionCommand,
ShowConfigCommand,
ConnectCommand,
LensesCommand,
}
// CountFlag represents a CLI flag to limit the number of report rows.

View file

@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/entity"
)
@ -83,6 +84,18 @@ func TestLensesCommand(t *testing.T) {
assert.Len(t, output, 0)
assert.Contains(t, err.Error(), `Required flag "make" not set`)
})
t.Run("UpdateWithEmptyMakeAndModel", func(t *testing.T) {
// Explicit empty strings satisfy the Required flag check, so the guard in UpdateMakeModel
// must reject them to prevent blanking a lens.
output, err := RunWithTestContext(LensesCommand, []string{"lenses", "update", "--id=1000002", "--make=", "--model="})
assert.Error(t, err)
assert.Len(t, output, 0)
assert.Contains(t, err.Error(), "make and model must not be empty")
var exitErr cli.ExitCoder
if assert.ErrorAs(t, err, &exitErr) {
assert.Equal(t, 1, exitErr.ExitCode())
}
})
t.Run("UpdateValid", func(t *testing.T) {
defer assert.NoError(t, entity.Db().Save(entity.LensFixtures.Pointer("4-37")).Error)
// Run command with test context.

View file

@ -171,6 +171,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
Folders: true,
Import: true,
Labels: true,
Cameras: true,
Lenses: true,
Library: true,
Logs: true,
@ -213,6 +214,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
Folders: true,
Import: false,
Labels: false,
Cameras: false,
Lenses: false,
Library: false,
Logs: false,
@ -255,6 +257,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
Folders: true,
Import: false,
Labels: false,
Cameras: false,
Lenses: false,
Library: false,
Logs: false,
@ -289,6 +292,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
assert.False(t, f.Calendar)
assert.False(t, f.Moments)
assert.False(t, f.Labels)
assert.False(t, f.Cameras)
assert.False(t, f.Lenses)
assert.False(t, f.People)
assert.False(t, f.Settings)
@ -334,6 +338,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.True(t, f.Calendar)
assert.True(t, f.Moments)
assert.True(t, f.Labels)
assert.True(t, f.Cameras)
assert.True(t, f.Lenses)
assert.True(t, f.People)
assert.True(t, f.Settings)
@ -367,6 +372,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.True(t, f.Calendar)
assert.True(t, f.Moments)
assert.True(t, f.Labels)
assert.True(t, f.Cameras)
assert.True(t, f.Lenses)
assert.True(t, f.People)
assert.True(t, f.Settings)
@ -399,6 +405,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.True(t, f.Albums)
assert.False(t, f.Moments)
assert.False(t, f.Labels)
assert.False(t, f.Cameras)
assert.False(t, f.Lenses)
assert.False(t, f.People)
assert.False(t, f.Settings)
@ -433,6 +440,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.True(t, f.Moments)
assert.True(t, f.Folders)
assert.False(t, f.Labels)
assert.False(t, f.Cameras)
assert.False(t, f.Lenses)
assert.False(t, f.People)
assert.False(t, f.Settings)
@ -467,6 +475,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.True(t, f.Moments)
assert.True(t, f.Folders)
assert.False(t, f.Labels)
assert.False(t, f.Cameras)
assert.False(t, f.Lenses)
assert.False(t, f.People)
assert.False(t, f.Settings)
@ -500,6 +509,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.False(t, f.Albums)
assert.False(t, f.Moments)
assert.False(t, f.Labels)
assert.False(t, f.Cameras)
assert.False(t, f.Lenses)
assert.False(t, f.People)
assert.False(t, f.Settings)
@ -531,6 +541,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.True(t, f.Calendar)
assert.True(t, f.Moments)
assert.True(t, f.Labels)
assert.True(t, f.Cameras)
assert.True(t, f.Lenses)
assert.True(t, f.People)
assert.True(t, f.Settings)
@ -564,6 +575,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.False(t, f.Moments)
assert.False(t, f.Folders)
assert.False(t, f.Labels)
assert.False(t, f.Cameras)
assert.False(t, f.Lenses)
assert.False(t, f.People)
assert.False(t, f.Settings)
@ -596,6 +608,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.False(t, f.Calendar)
assert.False(t, f.Moments)
assert.False(t, f.Labels)
assert.False(t, f.Cameras)
assert.False(t, f.Lenses)
assert.False(t, f.People)
assert.True(t, f.Settings)

View file

@ -13,6 +13,7 @@ func (s *Settings) ApplyACL(list acl.ACL, role acl.Role) *Settings {
m.Features.Favorites = s.Features.Favorites && list.AllowAny(acl.ResourceFavorites, role, acl.Permissions{acl.ActionSearch})
m.Features.Folders = s.Features.Folders && list.AllowAny(acl.ResourceFolders, role, acl.Permissions{acl.ActionSearch})
m.Features.Labels = s.Features.Labels && list.AllowAny(acl.ResourceLabels, role, acl.Permissions{acl.ActionSearch})
m.Features.Cameras = s.Features.Cameras && list.AllowAny(acl.ResourceCameras, role, acl.Permissions{acl.ActionSearch})
m.Features.Lenses = s.Features.Lenses && list.AllowAny(acl.ResourceLenses, role, acl.Permissions{acl.ActionSearch})
m.Features.Calendar = s.Features.Calendar && list.AllowAny(acl.ResourceCalendar, role, acl.Permissions{acl.ActionSearch})
m.Features.Moments = s.Features.Moments && list.AllowAny(acl.ResourceMoments, role, acl.Permissions{acl.ActionSearch})

View file

@ -27,6 +27,7 @@ func TestSettings_ApplyACL(t *testing.T) {
Folders: true,
Import: true,
Labels: true,
Cameras: true,
Lenses: true,
Library: true,
Logs: true,
@ -70,6 +71,7 @@ func TestSettings_ApplyACL(t *testing.T) {
Folders: true,
Import: false,
Labels: false,
Cameras: false,
Lenses: false,
Library: false,
Logs: false,

View file

@ -14,6 +14,7 @@ type FeatureSettings struct {
Folders bool `json:"folders" yaml:"Folders"`
Import bool `json:"import" yaml:"Import"`
Labels bool `json:"labels" yaml:"Labels"`
Cameras bool `json:"cameras" yaml:"Cameras"`
Lenses bool `json:"lenses" yaml:"Lenses"`
Library bool `json:"library" yaml:"Library"`
Logs bool `json:"logs" yaml:"Logs"`

View file

@ -21,7 +21,7 @@ func TestInitDefaultFeatures_DisableList(t *testing.T) {
DefaultFeatures = initDefaultFeatures()
})
_ = os.Setenv("PHOTOPRISM_DISABLE_FEATURES", "Upload, videos share batch-edit labels lenses")
_ = os.Setenv("PHOTOPRISM_DISABLE_FEATURES", "Upload, videos share batch-edit labels cameras lenses")
DefaultFeatures = initDefaultFeatures()
assert.False(t, DefaultFeatures.Upload)
@ -29,6 +29,7 @@ func TestInitDefaultFeatures_DisableList(t *testing.T) {
assert.False(t, DefaultFeatures.Share)
assert.False(t, DefaultFeatures.BatchEdit)
assert.False(t, DefaultFeatures.Labels)
assert.False(t, DefaultFeatures.Cameras)
assert.False(t, DefaultFeatures.Lenses)
// unaffected feature stays enabled

View file

@ -20,6 +20,7 @@ func (s *Settings) ApplyScope(scope string) *Settings {
m.Features.Favorites = s.Features.Favorites && scopes.Contains(acl.ResourceFavorites.String())
m.Features.Folders = s.Features.Folders && scopes.Contains(acl.ResourceFolders.String())
m.Features.Labels = s.Features.Labels && scopes.Contains(acl.ResourceLabels.String())
m.Features.Cameras = s.Features.Cameras && scopes.Contains(acl.ResourceCameras.String())
m.Features.Lenses = s.Features.Lenses && scopes.Contains(acl.ResourceLenses.String())
m.Features.Calendar = s.Features.Calendar && scopes.Contains(acl.ResourceCalendar.String())
m.Features.Moments = s.Features.Moments && scopes.Contains(acl.ResourceMoments.String())

View file

@ -31,6 +31,7 @@ func TestSettings_ApplyScope(t *testing.T) {
Folders: true,
Import: true,
Labels: true,
Cameras: true,
Lenses: true,
Library: true,
Logs: true,
@ -74,6 +75,7 @@ func TestSettings_ApplyScope(t *testing.T) {
Folders: false,
Import: false,
Labels: false,
Cameras: false,
Lenses: false,
Library: false,
Logs: false,
@ -117,6 +119,7 @@ func TestSettings_ApplyScope(t *testing.T) {
Folders: true,
Import: false,
Labels: false,
Cameras: false,
Lenses: false,
Library: false,
Logs: false,
@ -159,6 +162,7 @@ func TestSettings_ApplyScope(t *testing.T) {
Folders: true,
Import: false,
Labels: false,
Cameras: false,
Lenses: false,
Library: false,
Logs: false,
@ -201,6 +205,7 @@ func TestSettings_ApplyScope(t *testing.T) {
Folders: false,
Import: false,
Labels: false,
Cameras: false,
Lenses: false,
Library: false,
Logs: false,

View file

@ -27,6 +27,7 @@ Features:
Folders: true
Import: true
Labels: true
Cameras: true
Lenses: true
Library: true
Logs: true

View file

@ -1,11 +1,15 @@
package entity
import (
"fmt"
"strings"
"sync"
"time"
"github.com/ulule/deepcopier"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -35,6 +39,7 @@ func (Camera) TableName() string {
return "cameras"
}
// UnknownCamera is the placeholder used when no camera make or model is known.
var UnknownCamera = Camera{
CameraSlug: UnknownID,
CameraName: "Unknown",
@ -182,3 +187,58 @@ func (m *Camera) Mobile() bool {
func (m *Camera) Unknown() bool {
return m.CameraSlug == "" || m.CameraSlug == UnknownCamera.CameraSlug
}
// UpdateMakeModel updates the make and model of an existing camera, e.g. to fix entries that
// ExifTool decodes with a missing or garbled make.
// The camera slug is intentionally left unchanged so existing photo references and the unique slug
// index are preserved across renames.
func (m *Camera) UpdateMakeModel(makeName, modelName string) error {
if m.ID == 0 {
return fmt.Errorf("empty id")
}
makeName = strings.TrimSpace(makeName)
modelName = strings.TrimSpace(modelName)
if makeName == "" || modelName == "" {
return fmt.Errorf("make and model must not be empty")
}
cam := NewCamera(makeName, modelName)
// Override the changeable fields.
m.CameraMake = cam.CameraMake
m.CameraModel = cam.CameraModel
m.CameraName = cam.CameraName
m.CameraType = cam.CameraType
cameraMutex.Lock()
defer cameraMutex.Unlock()
if err := Db().Save(m).Error; err != nil {
return err
} else {
if !m.Unknown() {
event.EntitiesUpdated("cameras", []*Camera{m})
event.Publish("count.cameras", event.Data{
"count": 1,
})
}
cameraCache.SetDefault(m.CameraSlug, m)
}
return nil
}
// SaveForm validates the form, copies its data into the camera, and persists it.
func (m *Camera) SaveForm(f *form.Camera) error {
if f == nil {
return fmt.Errorf("form is nil")
} else if err := f.Validate(); err != nil {
return err
}
if err := deepcopier.Copy(m).From(f); err != nil {
return err
}
return m.UpdateMakeModel(f.CameraMake, f.CameraModel)
}

View file

@ -8,6 +8,7 @@ import (
var cameraCache = gc.New(time.Hour, 15*time.Minute)
// FlushCameraCache removes all cached cameras.
func FlushCameraCache() {
cameraCache.Flush()
}

View file

@ -1,5 +1,6 @@
package entity
// Camera make name constants used for normalization and device-type detection.
const (
MakeNone = ""
MakeAcer = "Acer"

View file

@ -1,5 +1,6 @@
package entity
// Camera model name constants used for normalization and device-type detection.
const (
ModelNone = ""
ModelUnknown = "Unknown"

View file

@ -4,6 +4,8 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/form"
)
func TestFirstOrCreateCamera(t *testing.T) {
@ -253,3 +255,76 @@ func TestCamera_Mobile(t *testing.T) {
assert.True(t, camera.Mobile())
})
}
func TestCamera_UpdateMakeModel(t *testing.T) {
t.Run("ExistingCamera", func(t *testing.T) {
fixture := "canon-eos-7d"
camera := NewCamera(CameraFixtures.Get(fixture).CameraMake, CameraFixtures.Get(fixture).CameraModel)
result := FirstOrCreateCamera(camera)
defer assert.NoError(t, UnscopedDb().Save(CameraFixtures.Pointer(fixture)).Error)
makeName := "Pentax"
modelName := "K-1"
err := result.UpdateMakeModel(makeName, modelName)
assert.NoError(t, err)
assert.Equal(t, CameraFixtures.Get(fixture).ID, result.ID)
assert.Equal(t, CameraMakes[makeName], result.CameraMake)
assert.Equal(t, modelName, result.CameraModel)
assert.Equal(t, CameraFixtures.Get(fixture).CameraSlug, result.CameraSlug) // Slug is preserved across renames.
assert.Equal(t, CameraMakes[makeName]+" "+modelName, result.CameraName)
})
t.Run("NewCamera", func(t *testing.T) {
setup := NewCamera("", "9 99")
camera := FirstOrCreateCamera(setup)
defer assert.NoError(t, UnscopedDb().Delete(&Camera{}, "id = ?", camera.ID).Error)
makeName := "Pentax"
modelName := "K-1"
err := camera.UpdateMakeModel(makeName, modelName)
assert.NoError(t, err)
assert.Equal(t, CameraMakes[makeName], camera.CameraMake)
assert.Equal(t, modelName, camera.CameraModel)
assert.Equal(t, "9-99", camera.CameraSlug) // Slug is preserved across renames.
assert.Equal(t, CameraMakes[makeName]+" "+modelName, camera.CameraName)
})
t.Run("NotExistingCamera", func(t *testing.T) {
camera := NewCamera("", "9 98")
err := camera.UpdateMakeModel("Pentax", "K-3")
assert.Error(t, err)
})
t.Run("EmptyMake", func(t *testing.T) {
camera := &Camera{ID: CameraFixtures.Get("canon-eos-7d").ID, CameraMake: "Canon", CameraModel: "EOS 7D", CameraName: "Canon EOS 7D", CameraSlug: "canon-eos-7d"}
err := camera.UpdateMakeModel(" ", "EOS 7D")
assert.Error(t, err)
// The guard returns before any mutation, so existing values must be untouched.
assert.Equal(t, "Canon", camera.CameraMake)
assert.Equal(t, "EOS 7D", camera.CameraModel)
})
t.Run("EmptyModel", func(t *testing.T) {
camera := &Camera{ID: CameraFixtures.Get("canon-eos-7d").ID, CameraMake: "Canon", CameraModel: "EOS 7D", CameraName: "Canon EOS 7D", CameraSlug: "canon-eos-7d"}
err := camera.UpdateMakeModel("Canon", "")
assert.Error(t, err)
assert.Equal(t, "Canon", camera.CameraMake)
assert.Equal(t, "EOS 7D", camera.CameraModel)
})
}
func TestCamera_SaveForm(t *testing.T) {
t.Run("Success", func(t *testing.T) {
fixture := "canon-eos-7d"
camera := FirstOrCreateCamera(NewCamera(CameraFixtures.Get(fixture).CameraMake, CameraFixtures.Get(fixture).CameraModel))
defer assert.NoError(t, UnscopedDb().Save(CameraFixtures.Pointer(fixture)).Error)
err := camera.SaveForm(&form.Camera{CameraMake: "Pentax", CameraModel: "K-1"})
assert.NoError(t, err)
assert.Equal(t, CameraMakes["Pentax"], camera.CameraMake)
assert.Equal(t, "K-1", camera.CameraModel)
})
t.Run("NilForm", func(t *testing.T) {
camera := &Camera{ID: CameraFixtures.Get("canon-eos-7d").ID}
assert.Error(t, camera.SaveForm(nil))
})
t.Run("EmptyMake", func(t *testing.T) {
camera := &Camera{ID: CameraFixtures.Get("canon-eos-7d").ID}
assert.Error(t, camera.SaveForm(&form.Camera{CameraMake: "", CameraModel: "K-1"}))
})
}

View file

@ -39,6 +39,7 @@ func (Lens) TableName() string {
return "lenses"
}
// UnknownLens is the placeholder used when no lens make or model is known.
var UnknownLens = Lens{
LensSlug: UnknownID,
LensName: "Unknown",
@ -158,21 +159,31 @@ func (m *Lens) Unknown() bool {
return m.LensSlug == "" || m.LensSlug == UnknownLens.LensSlug
}
// UpdateMakeModel updates the make and model for an existing lens. Primarily intended for Pentax lens.
// UpdateMakeModel updates the make and model of an existing lens, e.g. to fix Pentax models that
// ExifTool decodes as a numeric "4 38".
// The lens slug is intentionally left unchanged so existing photo references and the unique slug
// index are preserved across renames.
func (m *Lens) UpdateMakeModel(makeName, modelName string) error {
if m.ID == 0 {
return fmt.Errorf("empty id")
}
makeName = strings.TrimSpace(makeName)
modelName = strings.TrimSpace(modelName)
if makeName == "" || modelName == "" {
return fmt.Errorf("make and model must not be empty")
}
l := NewLens(makeName, modelName)
// Override the changeable fields
// Override the changeable fields.
m.LensMake = l.LensMake
m.LensModel = l.LensModel
m.LensName = l.LensName
lensMutex.Lock()
defer lensMutex.Unlock()
if err := Db().Save(&m).Error; err != nil {
if err := Db().Save(m).Error; err != nil {
return err
} else {
if !m.Unknown() {
@ -187,12 +198,12 @@ func (m *Lens) UpdateMakeModel(makeName, modelName string) error {
return nil
}
// SaveForm copies validated form data into the label and persists it.
// SaveForm validates the form, copies its data into the lens, and persists it.
func (m *Lens) SaveForm(f *form.Lens) error {
if f == nil {
return fmt.Errorf("form is nil")
} else if f.LensMake == "" || txt.Slug(f.LensModel) == "" {
return fmt.Errorf("make and model must not be empty")
} else if err := f.Validate(); err != nil {
return err
}
if err := deepcopier.Copy(m).From(f); err != nil {

View file

@ -8,6 +8,7 @@ import (
var lensCache = gc.New(time.Hour, 15*time.Minute)
// FlushLensCache removes all cached lenses.
func FlushLensCache() {
lensCache.Flush()
}

View file

@ -4,6 +4,8 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/form"
)
func TestNewLens(t *testing.T) {
@ -145,4 +147,40 @@ func TestLensUpdateMakeModel(t *testing.T) {
err := lens.UpdateMakeModel("Pentax", "smc PENTAX-FA 31mm F1.8 AL Limited")
assert.Error(t, err)
})
t.Run("EmptyMake", func(t *testing.T) {
lens := &Lens{ID: LensFixtures.Get("lens-f-380").ID, LensMake: "Apple", LensModel: "F380", LensName: "Apple F380", LensSlug: "lens-f-380"}
err := lens.UpdateMakeModel(" ", "F380")
assert.Error(t, err)
// The guard returns before any mutation, so existing values must be untouched.
assert.Equal(t, "Apple", lens.LensMake)
assert.Equal(t, "F380", lens.LensModel)
})
t.Run("EmptyModel", func(t *testing.T) {
lens := &Lens{ID: LensFixtures.Get("lens-f-380").ID, LensMake: "Apple", LensModel: "F380", LensName: "Apple F380", LensSlug: "lens-f-380"}
err := lens.UpdateMakeModel("Apple", "")
assert.Error(t, err)
assert.Equal(t, "Apple", lens.LensMake)
assert.Equal(t, "F380", lens.LensModel)
})
}
func TestLens_SaveForm(t *testing.T) {
t.Run("Success", func(t *testing.T) {
lens := Lens{}
assert.NoError(t, UnscopedDb().First(&lens, "id = ?", LensFixtures.Get("lens-f-380").ID).Error)
defer assert.NoError(t, UnscopedDb().Save(LensFixtures.Pointer("lens-f-380")).Error)
err := lens.SaveForm(&form.Lens{LensMake: "Sigma", LensModel: "85mm F1.4"})
assert.NoError(t, err)
assert.Equal(t, CameraMakes["Sigma"], lens.LensMake) // NewLens normalizes the make.
assert.Equal(t, "85mm F1.4", lens.LensModel)
assert.Equal(t, "lens-f-380", lens.LensSlug) // Slug is preserved across renames.
})
t.Run("NilForm", func(t *testing.T) {
lens := &Lens{ID: LensFixtures.Get("lens-f-380").ID}
assert.Error(t, lens.SaveForm(nil))
})
t.Run("EmptyMake", func(t *testing.T) {
lens := &Lens{ID: LensFixtures.Get("lens-f-380").ID}
assert.Error(t, lens.SaveForm(&form.Lens{LensMake: "", LensModel: "85mm F1.4"}))
})
}

View file

@ -0,0 +1,35 @@
package query
import (
"github.com/photoprism/photoprism/internal/entity"
)
// FindCameraBySlug returns an existing entity if exists.
func FindCameraBySlug(slug string) *entity.Camera {
if slug == "" {
return nil
}
c := entity.Camera{}
if err := Db().Where("camera_slug = ?", slug).First(&c).Error; err != nil {
return nil
}
return &c
}
// FindCameraByID returns an existing entity if exists.
func FindCameraByID(id uint) *entity.Camera {
if id == 0 {
return nil
}
c := entity.Camera{}
if err := Db().Where("id = ?", id).First(&c).Error; err != nil {
return nil
}
return &c
}

View file

@ -0,0 +1,47 @@
package query
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity"
)
func TestFindCameraBySlug(t *testing.T) {
t.Run("ExistingCamera", func(t *testing.T) {
camera := FindCameraBySlug(entity.CameraFixtures.Get("canon-eos-7d").CameraSlug)
assert.NotNil(t, camera)
if camera != nil {
assert.Equal(t, entity.CameraFixtures.Get("canon-eos-7d").ID, camera.ID)
assert.Equal(t, entity.CameraFixtures.Get("canon-eos-7d").CameraModel, camera.CameraModel)
}
})
t.Run("NotExistingCamera", func(t *testing.T) {
camera := FindCameraBySlug("IAmNotValid")
assert.Nil(t, camera)
})
t.Run("EmptySlug", func(t *testing.T) {
camera := FindCameraBySlug("")
assert.Nil(t, camera)
})
}
func TestFindCameraByID(t *testing.T) {
t.Run("ExistingCamera", func(t *testing.T) {
camera := FindCameraByID(entity.CameraFixtures.Get("canon-eos-7d").ID)
assert.NotNil(t, camera)
if camera != nil {
assert.Equal(t, entity.CameraFixtures.Get("canon-eos-7d").ID, camera.ID)
assert.Equal(t, entity.CameraFixtures.Get("canon-eos-7d").CameraModel, camera.CameraModel)
}
})
t.Run("NotExistingCamera", func(t *testing.T) {
camera := FindCameraByID(99885541348)
assert.Nil(t, camera)
})
t.Run("ZeroID", func(t *testing.T) {
camera := FindCameraByID(0)
assert.Nil(t, camera)
})
}

View file

@ -34,12 +34,8 @@ func PurgeOrphans() error {
return err
}
// Remove unused camera lenses.
if err := PurgeOrphanLenses(); err != nil {
return err
}
return nil
// Remove unused lenses.
return PurgeOrphanLenses()
}
// PurgeOrphanFiles removes files without a photo from the index.

View file

@ -0,0 +1,20 @@
package search
import (
"time"
)
// Camera represents a camera search result.
type Camera struct {
ID uint `json:"ID"`
CameraSlug string `json:"Slug"`
CameraName string `json:"Name"`
CameraMake string `json:"Make"`
CameraModel string `json:"Model"`
CameraType string `json:"Type"`
CameraDescription string `json:"Description"`
CameraNotes string `json:"Notes"`
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
DeletedAt time.Time `json:"DeletedAt"`
}

View file

@ -0,0 +1,57 @@
package search
import (
"strings"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/txt"
)
// Cameras searches cameras based on their name.
func Cameras(frm form.SearchCameras) (results []Camera, err error) {
if err = frm.ParseQueryString(); err != nil {
return results, err
}
s := Db()
// Base query.
s = s.Table("cameras").
Select(`cameras.*`)
// Filter cameras without make.
if frm.NoMake {
s = s.Where("cameras.camera_make = ''")
}
// Limit result count.
if frm.Count > 0 && frm.Count <= MaxResults {
s = s.Limit(frm.Count).Offset(frm.Offset)
} else {
s = s.Limit(MaxResults).Offset(frm.Offset)
}
// Set sort order.
s = s.Order(OrderExpr("cameras.camera_make ASC, cameras.camera_model ASC, cameras.camera_slug ASC", frm.Reverse))
if frm.ID != "" {
s = s.Where("cameras.id IN (?)", strings.Split(frm.ID, txt.Or))
if result := s.Scan(&results); result.Error != nil {
return results, result.Error
}
return results, nil
}
if frm.Query != "" {
likeString := SqlParam(frm.Query, "%", "%")
s = s.Where("cameras.camera_name LIKE ? OR cameras.camera_make LIKE ? OR cameras.camera_model LIKE ?", likeString, likeString, likeString)
}
if result := s.Scan(&results); result.Error != nil {
return results, result.Error
}
return results, nil
}

View file

@ -0,0 +1,125 @@
package search
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
)
func TestCameras(t *testing.T) {
t.Run("SearchWithQuery", func(t *testing.T) {
query := form.NewCameraSearch("q:Canon")
query.Count = 1005
result, err := Cameras(query)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 2, len(result))
for _, r := range result {
assert.IsType(t, Camera{}, r)
assert.NotEmpty(t, r.ID)
assert.NotEmpty(t, r.CameraName)
assert.NotEmpty(t, r.CameraSlug)
assert.True(t, strings.Contains(strings.ToLower(r.CameraName), "canon") || strings.Contains(strings.ToLower(r.CameraMake), "canon") || strings.Contains(strings.ToLower(r.CameraModel), "canon"))
if fix, ok := entity.CameraFixtures[r.CameraSlug]; ok {
assert.Equal(t, fix.CameraName, r.CameraName)
assert.Equal(t, fix.CameraSlug, r.CameraSlug)
assert.Equal(t, fix.CameraMake, r.CameraMake)
assert.Equal(t, fix.CameraModel, r.CameraModel)
}
}
})
t.Run("SearchForString", func(t *testing.T) {
query := form.NewCameraSearch("Q:EOS 5D")
query.Count = 1005
result, err := Cameras(query)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 1, len(result))
for _, r := range result {
assert.IsType(t, Camera{}, r)
assert.NotEmpty(t, r.ID)
assert.NotEmpty(t, r.CameraName)
assert.NotEmpty(t, r.CameraSlug)
assert.NotEmpty(t, r.CameraModel)
}
})
t.Run("SearchForNoMake", func(t *testing.T) {
query := form.NewCameraSearch("NoMake:true")
query.Count = 15
result, err := Cameras(query)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 1, len(result))
for _, r := range result {
assert.IsType(t, Camera{}, r)
assert.NotEmpty(t, r.ID)
assert.NotEmpty(t, r.CameraName)
assert.NotEmpty(t, r.CameraSlug)
assert.Empty(t, r.CameraMake)
}
})
t.Run("SearchWithEmptyQuery", func(t *testing.T) {
query := form.NewCameraSearch("")
result, err := Cameras(query)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 6, len(result))
})
t.Run("SearchWithReverse", func(t *testing.T) {
query := form.NewCameraSearch("")
query.Reverse = true
result, err := Cameras(query)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 6, len(result))
})
t.Run("SearchForNoMakeAndQueryNoResults", func(t *testing.T) {
query := form.NewCameraSearch("Canon")
query.NoMake = true
result, err := Cameras(query)
assert.NoError(t, err)
assert.Empty(t, result)
})
t.Run("SearchWithInvalidQueryString", func(t *testing.T) {
query := form.NewCameraSearch("xxx:bla")
result, err := Cameras(query)
assert.Error(t, err, "unknown filter")
assert.Empty(t, result)
})
t.Run("SearchForId", func(t *testing.T) {
f := form.SearchCameras{
ID: "1000000|1000001",
Count: 0,
}
result, err := Cameras(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, result, 2)
})
}

39
internal/form/camera.go Normal file
View file

@ -0,0 +1,39 @@
package form
import (
"github.com/ulule/deepcopier"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/txt"
)
// Camera represents a camera edit form.
type Camera struct {
CameraMake string `json:"Make"`
CameraModel string `json:"Model"`
}
// NewCamera creates a new form struct based on the interface values.
func NewCamera(m any) (*Camera, error) {
frm := &Camera{}
err := deepcopier.Copy(m).To(frm)
return frm, err
}
// Validate returns an error if any form values are invalid.
func (frm *Camera) Validate() error {
cameraMake := txt.Clip(frm.CameraMake, txt.ClipName)
cameraModel := txt.Clip(frm.CameraModel, txt.ClipName)
if cameraMake == "" || cameraModel == "" {
return i18n.Error(i18n.ErrInvalidName)
}
cameraSlug := txt.Slug(cameraMake + " " + cameraModel)
if cameraSlug == "" {
return i18n.Error(i18n.ErrInvalidName)
}
return nil
}

View file

@ -0,0 +1,44 @@
package form
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewCamera(t *testing.T) {
t.Run("Success", func(t *testing.T) {
var c = struct {
CameraMake string
CameraModel string
}{
CameraMake: "New Make",
CameraModel: "New Model",
}
result, err := NewCamera(c)
if err != nil {
t.Fatal(err)
}
assert.IsType(t, &Camera{}, result)
assert.Equal(t, "New Make", result.CameraMake)
assert.Equal(t, "New Model", result.CameraModel)
})
}
func TestCamera_Validate(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
frm := &Camera{CameraMake: "Canon", CameraModel: "EOS 5D"}
assert.NoError(t, frm.Validate())
})
t.Run("EmptyMake", func(t *testing.T) {
frm := &Camera{CameraMake: "", CameraModel: "EOS 5D"}
assert.Error(t, frm.Validate())
})
t.Run("EmptyModel", func(t *testing.T) {
frm := &Camera{CameraMake: "Canon", CameraModel: ""}
assert.Error(t, frm.Validate())
})
}

View file

@ -0,0 +1,33 @@
package form
// SearchCameras represents search form fields for command or "/api/v1/cameras".
type SearchCameras struct {
Query string `form:"q"`
ID string `form:"id"`
Slug string `form:"slug"`
Name string `form:"name"`
NoMake bool `form:"nomake"`
Count int `form:"count" binding:"required" serialize:"-"`
Offset int `form:"offset" serialize:"-"`
Reverse bool `form:"reverse" serialize:"-"`
}
// GetQuery returns the current search query string.
func (f *SearchCameras) GetQuery() string {
return f.Query
}
// SetQuery stores the raw query string.
func (f *SearchCameras) SetQuery(q string) {
f.Query = q
}
// ParseQueryString deserializes the query string into form fields.
func (f *SearchCameras) ParseQueryString() error {
return ParseQueryString(f)
}
// NewCameraSearch creates a SearchCameras form with the provided query.
func NewCameraSearch(query string) SearchCameras {
return SearchCameras{Query: query}
}

View file

@ -185,6 +185,10 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.GetFace(APIv1)
api.UpdateFace(APIv1)
// Cameras.
api.SearchCameras(APIv1)
api.UpdateCamera(APIv1)
// Lenses.
api.SearchLenses(APIv1)
api.UpdateLens(APIv1)

View file

@ -21,6 +21,7 @@ const (
ErrAccountNotFound
ErrUserNotFound
ErrLabelNotFound
ErrCameraNotFound
ErrLensNotFound
ErrAlbumNotFound
ErrSubjectNotFound
@ -123,6 +124,7 @@ var Messages = MessageMap{
ErrAccountNotFound: gettext("Account not found"),
ErrUserNotFound: gettext("User not found"),
ErrLabelNotFound: gettext("Label not found"),
ErrCameraNotFound: gettext("Camera not found"),
ErrLensNotFound: gettext("Lens not found"),
ErrAlbumNotFound: gettext("Album not found"),
ErrSubjectNotFound: gettext("Subject not found"),