Indexing bug fixes and UX improvements

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-06-04 14:56:27 +02:00
parent 73c4891cde
commit ca8a8466d4
27 changed files with 404 additions and 263 deletions

View file

@ -7,7 +7,7 @@ ENV TF_CPP_MIN_LOG_LEVEL 2
COPY /docker/demo/index.tmpl /photoprism/assets/templates COPY /docker/demo/index.tmpl /photoprism/assets/templates
# Download example photos # Download example photos
RUN wget -qO- https://dl.photoprism.org/fixtures/demo.tar.gz | tar xvz -C /photoprism/import RUN wget -qO- https://dl.photoprism.org/fixtures/demo.tar.gz | tar xvz -C /photoprism/originals
# Configure PhotoPrism # Configure PhotoPrism
ENV PHOTOPRISM_STORAGE_PATH /photoprism/storage ENV PHOTOPRISM_STORAGE_PATH /photoprism/storage
@ -27,9 +27,11 @@ ENV PHOTOPRISM_THUMB_SIZE 3840
ENV PHOTOPRISM_THUMB_LIMIT 3840 ENV PHOTOPRISM_THUMB_LIMIT 3840
ENV PHOTOPRISM_JPEG_QUALITY 95 ENV PHOTOPRISM_JPEG_QUALITY 95
ENV PHOTOPRISM_JPEG_HIDDEN false ENV PHOTOPRISM_JPEG_HIDDEN false
ENV PHOTOPRISM_SITE_CAPTION "Try our demo"
# Import example photos # Import example photos
RUN photoprism import RUN photoprism index
RUN photoprism moments
# Start PhotoPrism server # Start PhotoPrism server
CMD photoprism --public start CMD photoprism --public start

View file

@ -296,7 +296,7 @@
</v-list-tile-content> </v-list-tile-content>
</v-list-tile> </v-list-tile>
<v-list-tile to="/files" @click="" class="p-navigation-files" v-show="$config.feature('files')"> <v-list-tile to="/library/files" @click="" class="p-navigation-files" v-show="$config.feature('files')">
<v-list-tile-content> <v-list-tile-content>
<v-list-tile-title> <v-list-tile-title>
<translate key="Files">Files</translate> <translate key="Files">Files</translate>
@ -304,6 +304,15 @@
</v-list-tile-title> </v-list-tile-title>
</v-list-tile-content> </v-list-tile-content>
</v-list-tile> </v-list-tile>
<v-list-tile to="/library/hidden" @click="" class="p-navigation-hidden">
<v-list-tile-content>
<v-list-tile-title>
<translate key="Hidden">Hidden</translate>
<span v-show="config.count.hidden > 0" class="p-navigation-count">{{ config.count.hidden }}</span>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list-group> </v-list-group>
<v-list-tile to="/settings" @click="" class="p-navigation-settings" v-show="!config.disableSettings"> <v-list-tile to="/settings" @click="" class="p-navigation-settings" v-show="!config.disableSettings">

View file

@ -16,7 +16,38 @@
<v-container fluid> <v-container fluid>
<p class="subheading"> <p class="subheading">
<span v-if="total === 0">Select files to start upload...</span> <v-combobox v-if="total === 0" flat solo hide-details chips deletable-chips
multiple color="secondary-dark" class="my-0"
v-model="selectedAlbums"
:items="albums"
item-text="Title"
item-value="UID"
:allow-overflow="false"
label="Select albums or create a new one"
return-object
>
<template v-slot:no-data>
<v-list-tile>
<v-list-tile-content>
<v-list-tile-title>
Press <kbd>enter</kbd> to create a new album.
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</template>
<template v-slot:selection="data">
<v-chip
:key="JSON.stringify(data.item)"
:selected="data.selected"
:disabled="data.disabled"
class="v-chip--select-multi"
@input="data.parent.selectItem(data.item)"
>
<v-icon class="pr-1">folder</v-icon>
{{ data.item.Title ? data.item.Title : data.item | truncate(40) }}
</v-chip>
</template>
</v-combobox>
<span v-else-if="failed">Upload failed</span> <span v-else-if="failed">Upload failed</span>
<span v-else-if="total > 0 && completed < 100"> <span v-else-if="total > 0 && completed < 100">
Uploading {{current}} of {{total}}... Uploading {{current}} of {{total}}...
@ -25,38 +56,6 @@
<span v-else-if="completed === 100">Done.</span> <span v-else-if="completed === 100">Done.</span>
</p> </p>
<v-combobox flat solo hide-details chips deletable-chips
multiple color="secondary-dark" class="my-3"
v-model="selectedAlbums"
:items="albums"
item-text="Title"
item-value="UID"
:allow-overflow="false"
label="Add to existing albums or create a new one."
return-object
>
<template v-slot:no-data>
<v-list-tile>
<v-list-tile-content>
<v-list-tile-title>
Press <kbd>enter</kbd> to create a new album.
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</template>
<template v-slot:selection="data">
<v-chip
:key="JSON.stringify(data.item)"
:selected="data.selected"
:disabled="data.disabled"
class="v-chip--select-multi"
@input="data.parent.selectItem(data.item)"
>
<v-icon class="pr-1">folder</v-icon>
{{ data.item.Title ? data.item.Title : data.item | truncate(40) }}
</v-chip>
</template>
</v-combobox>
<v-progress-linear color="secondary-dark" v-model="completed" <v-progress-linear color="secondary-dark" v-model="completed"
:indeterminate="indexing"></v-progress-linear> :indeterminate="indexing"></v-progress-linear>

View file

@ -369,6 +369,10 @@ export class Photo extends RestModel {
info.push(Util.duration(file.Duration)); info.push(Util.duration(file.Duration));
} }
if (file.Codec) {
info.push(file.Codec.toUpperCase());
}
this.addSizeInfo(file, info); this.addSizeInfo(file, info);
if (!info) { if (!info) {

View file

@ -1,93 +0,0 @@
<template>
<div>
<v-toolbar flat color="secondary">
<v-toolbar-title>Events</v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>
<v-container>
<v-layout wrap>
<v-flex
xs12
class="mb-3"
>
<v-sheet height="500">
<v-calendar
ref="calendar"
v-model="start"
:type="type"
:end="end"
color="primary"
></v-calendar>
</v-sheet>
</v-flex>
<v-flex
sm4
xs12
class="text-sm-left text-xs-center"
>
<v-btn @click="$refs.calendar.prev()">
<v-icon
dark
left
>
keyboard_arrow_left
</v-icon>
<translate>Prev</translate>
</v-btn>
</v-flex>
<v-flex
sm4
xs12
class="text-xs-center"
>
<v-select
v-model="type"
:items="typeOptions"
:label="labels.type"
></v-select>
</v-flex>
<v-flex
sm4
xs12
class="text-sm-right text-xs-center"
>
<v-btn @click="$refs.calendar.next()">
<translate>Next</translate>
<v-icon
right
dark
>
keyboard_arrow_right
</v-icon>
</v-btn>
</v-flex>
</v-layout>
</v-container>
</div>
</template>
<script>
export default {
name: 'calendar',
data: () => ({
type: 'month',
start: '2019-01-01',
end: '2019-01-06',
typeOptions: [
{text: this.$gettext('Day'), value: 'day'},
{text: this.$gettext('4 Day'), value: '4day'},
{text: this.$gettext('Week'), value: 'week'},
{text: this.$gettext('Month'), value: 'month'},
{text: this.$gettext('Custom Daily'), value: 'custom-daily'},
{text: this.$gettext('Custom Weekly'), value: 'custom-weekly'}
],
labels: {
type: this.$gettext("Type"),
},
}),
methods: {}
};
</script>

View file

@ -40,6 +40,19 @@
}, },
data() { data() {
const s = this.$config.values.settings.maps; const s = this.$config.values.settings.maps;
const filter = {
q: this.query(),
};
const settings = this.$config.settings();
if (settings && settings.features.private) {
filter.public = true;
}
if (settings && settings.features.review && (!this.staticFilter || !("quality" in this.staticFilter))) {
filter.quality = 3;
}
return { return {
initialized: false, initialized: false,
@ -58,9 +71,7 @@
}, },
photos: [], photos: [],
result: {}, result: {},
filter: { filter: filter,
q: this.query(),
},
lastFilter: {}, lastFilter: {},
labels: { labels: {
search: this.$gettext("Search"), search: this.$gettext("Search"),

View file

@ -1,29 +0,0 @@
<template>
<div>
<v-toolbar flat color="secondary">
<v-toolbar-title>Not implemented yet</v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>
<v-container>
<p>
Issues labeled <a href="https://github.com/photoprism/photoprism/labels/help%20wanted">help wanted</a> /
<a href="https://github.com/photoprism/photoprism/labels/easy">easy</a> can be good (first)
contributions.
Our <a href="https://github.com/photoprism/photoprism/wiki">Developer Guide</a> contains all information
necessary to get you started.
</p>
</v-container>
</div>
</template>
<script>
export default {
name: 'todo',
data() {
return {};
},
methods: {}
};
</script>

View file

@ -1,29 +0,0 @@
<template>
<div class="p-page p-page-todo">
<v-toolbar flat color="secondary">
<v-toolbar-title>Not implemented yet</v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>
<v-container>
<p>
Issues labeled <a href="https://github.com/photoprism/photoprism/labels/help%20wanted">help wanted</a> /
<a href="https://github.com/photoprism/photoprism/labels/easy">easy</a> can be good (first)
contributions.
Our <a href="https://github.com/photoprism/photoprism/wiki">Developer Guide</a> contains all information
necessary to get you started.
</p>
</v-container>
</div>
</template>
<script>
export default {
name: 'p-page-todo',
data() {
return {};
},
methods: {}
};
</script>

View file

@ -2,15 +2,13 @@ import Photos from "pages/photos.vue";
import Albums from "pages/albums.vue"; import Albums from "pages/albums.vue";
import AlbumPhotos from "pages/album/photos.vue"; import AlbumPhotos from "pages/album/photos.vue";
import Places from "pages/places.vue"; import Places from "pages/places.vue";
import Files from "pages/files.vue"; import Files from "pages/library/files.vue";
import Labels from "pages/labels.vue"; import Labels from "pages/labels.vue";
import People from "pages/people.vue"; import People from "pages/people.vue";
import Library from "pages/library.vue"; import Library from "pages/library.vue";
import Share from "pages/share.vue";
import Settings from "pages/settings.vue"; import Settings from "pages/settings.vue";
import Login from "pages/login.vue"; import Login from "pages/login.vue";
import Discover from "pages/discover.vue"; import Discover from "pages/discover.vue";
import Todo from "pages/todo.vue";
const c = window.__CONFIG__; const c = window.__CONFIG__;
@ -134,10 +132,16 @@ export default [
}, },
{ {
name: "files", name: "files",
path: "/files*", path: "/library/files*",
component: Files, component: Files,
meta: {title: "File Browser", auth: true}, meta: {title: "File Browser", auth: true},
}, },
{
name: "hidden",
path: "/library/hidden",
component: Photos,
props: {staticFilter: {hidden: true}},
},
{ {
name: "labels", name: "labels",
path: "/labels", path: "/labels",
@ -157,12 +161,6 @@ export default [
component: People, component: People,
meta: {title: "People", auth: true}, meta: {title: "People", auth: true},
}, },
{
name: "filters",
path: "/filters",
component: Todo,
meta: {title: "Filters", auth: true},
},
{ {
name: "library_logs", name: "library_logs",
path: "/library/logs", path: "/library/logs",
@ -184,12 +182,6 @@ export default [
meta: {title: "Originals", auth: true, background: "application-light"}, meta: {title: "Originals", auth: true, background: "application-light"},
props: {tab: 0}, props: {tab: 0},
}, },
{
name: "share",
path: "/share",
component: Share,
meta: {title: "Share with friends", auth: true},
},
{ {
name: "settings", name: "settings",
path: "/settings", path: "/settings",

View file

@ -33,14 +33,45 @@ var UnknownCamera = Camera{
var CameraMakes = map[string]string{ var CameraMakes = map[string]string{
"OLYMPUS OPTICAL CO.,LTD": "Olympus", "OLYMPUS OPTICAL CO.,LTD": "Olympus",
"samsung": "Samsung",
} }
var CameraModels = map[string]string{ var CameraModels = map[string]string{
"ELE-L29": "P30", "ELE-L29": "P30",
"ELE-AL00": "P30", "ELE-AL00": "P30",
"ELE-L04": "P30", "ELE-L04": "P30",
"ELE-L09": "P30", "ELE-L09": "P30",
"ELE-TL00": "P30", "ELE-TL00": "P30",
"VOG-L29": "P30 Pro",
"VOG-L09": "P30 Pro",
"VOG-L04": "P30 Pro",
"VOG-AL00": "P30 Pro",
"VOG-AL10": "P30 Pro",
"VOG-TL00": "P30 Pro",
"MAR-L01A": "P30 Lite",
"MAR-L21A": "P30 Lite",
"MAR-LX1A": "P30 Lite",
"MAR-LX1M": "P30 Lite",
"MAR-LX2": "P30 Lite",
"MAR-L21MEA": "P30 Lite",
"MAR-L22A": "P30 Lite",
"MAR-L22B": "P30 Lite",
"MAR-LX3A": "P30 Lite",
"ANA-AN00": "P40",
"ANA-TN00": "P40",
"ELS-AN00": "P40 Pro",
"ELS-TN00": "P40 Pro",
"ELS-NX9": "P40 Pro",
"ELS-N04": "P40 Pro",
"JNY-L21A": "P40 Lite",
"JNY-L01A": "P40 Lite",
"JNY-L21B": "P40 Lite",
"JNY-L22A": "P40 Lite",
"JNY-L02A": "P40 Lite",
"JNY-L22B": "P40 Lite",
"STK-LX1": "Honor 9X",
"HLK-AL00": "Honor 9X",
"HLK-TL00": "Honor 9X",
} }
// CreateUnknownCamera initializes the database with an unknown camera if not exists // CreateUnknownCamera initializes the database with an unknown camera if not exists

View file

@ -419,7 +419,18 @@ func (m *Photo) LoadPlace() error {
} }
var place Place var place Place
return Db().Set("gorm:auto_preload", true).Model(m).Related(&place, "Place").Error
err := Db().Set("gorm:auto_preload", true).Model(m).Related(&place, "Place").Error
if m.Place == nil {
m.Place = &place
}
if m.Place.Unknown() {
m.Place = &UnknownPlace
}
return err
} }
// HasLatLng checks if the photo has a latitude and longitude. // HasLatLng checks if the photo has a latitude and longitude.

View file

@ -102,7 +102,7 @@ func (m *Photo) UpdateLocation(geoApi string) (keywords []string, labels classif
if m.UnknownLocation() { if m.UnknownLocation() {
m.Location = &UnknownLocation m.Location = &UnknownLocation
m.LocationID = UnknownLocation.ID m.LocationID = UnknownLocation.ID
} else if err := m.LoadLocation(); err == nil { } else if err := m.LoadLocation(); err == nil && m.Location != nil && m.Location.Place != nil {
m.Place = m.Location.Place m.Place = m.Location.Place
m.PlaceID = m.Location.PlaceID m.PlaceID = m.Location.PlaceID
} }
@ -110,7 +110,7 @@ func (m *Photo) UpdateLocation(geoApi string) (keywords []string, labels classif
if m.UnknownPlace() { if m.UnknownPlace() {
m.Place = &UnknownPlace m.Place = &UnknownPlace
m.PlaceID = UnknownPlace.ID m.PlaceID = UnknownPlace.ID
} else if err := m.LoadPlace(); err == nil { } else if err := m.LoadPlace(); err == nil && m.Place != nil {
m.PhotoCountry = m.Place.CountryCode() m.PhotoCountry = m.Place.CountryCode()
} }

View file

@ -14,13 +14,16 @@ type GeoSearch struct {
Favorite bool `form:"favorite"` Favorite bool `form:"favorite"`
Video bool `form:"video"` Video bool `form:"video"`
Photo bool `form:"photo"` Photo bool `form:"photo"`
Archived bool `form:"archived"`
Public bool `form:"public"`
Private bool `form:"private"`
Review bool `form:"review"`
Quality int `form:"quality"`
Lat float32 `form:"lat"` Lat float32 `form:"lat"`
Lng float32 `form:"lng"` Lng float32 `form:"lng"`
S2 string `form:"s2"` S2 string `form:"s2"`
Olc string `form:"olc"` Olc string `form:"olc"`
Dist uint `form:"dist"` Dist uint `form:"dist"`
Quality int `form:"quality"`
Review bool `form:"review"`
Album string `form:"album"` Album string `form:"album"`
Country string `form:"country"` Country string `form:"country"`
Year int `form:"year"` Year int `form:"year"`

View file

@ -19,8 +19,13 @@ type PhotoSearch struct {
Video bool `form:"video"` Video bool `form:"video"`
Photo bool `form:"photo"` Photo bool `form:"photo"`
Duplicate bool `form:"duplicate"` Duplicate bool `form:"duplicate"`
Archived bool `form:"archived"`
Error bool `form:"error"` Error bool `form:"error"`
Hidden bool `form:"hidden"`
Archived bool `form:"archived"`
Public bool `form:"public"`
Private bool `form:"private"`
Favorite bool `form:"favorite"`
Safe bool `form:"safe"`
Lat float32 `form:"lat"` Lat float32 `form:"lat"`
Lng float32 `form:"lng"` Lng float32 `form:"lng"`
Dist uint `form:"dist"` Dist uint `form:"dist"`
@ -45,10 +50,6 @@ type PhotoSearch struct {
Lens int `form:"lens"` Lens int `form:"lens"`
Before time.Time `form:"before" time_format:"2006-01-02"` Before time.Time `form:"before" time_format:"2006-01-02"`
After time.Time `form:"after" time_format:"2006-01-02"` After time.Time `form:"after" time_format:"2006-01-02"`
Favorite bool `form:"favorite"`
Public bool `form:"public"`
Private bool `form:"private"`
Safe bool `form:"safe"`
Count int `form:"count" binding:"required" serialize:"-"` Count int `form:"count" binding:"required" serialize:"-"`
Offset int `form:"offset" serialize:"-"` Offset int `form:"offset" serialize:"-"`
Order string `form:"order" serialize:"-"` Order string `form:"order" serialize:"-"`

View file

@ -49,8 +49,8 @@ type Data struct {
// AspectRatio returns the aspect ratio based on width and height. // AspectRatio returns the aspect ratio based on width and height.
func (data Data) AspectRatio() float32 { func (data Data) AspectRatio() float32 {
width := float64(data.Width) width := float64(data.ActualWidth())
height := float64(data.Height) height := float64(data.ActualHeight())
if width <= 0 || height <= 0 { if width <= 0 || height <= 0 {
return 0 return 0
@ -85,3 +85,21 @@ func (data Data) HasInstanceID() bool {
func (data Data) HasTimeAndPlace() bool { func (data Data) HasTimeAndPlace() bool {
return !data.TakenAt.IsZero() && data.Lat != 0 && data.Lng != 0 return !data.TakenAt.IsZero() && data.Lat != 0 && data.Lng != 0
} }
// ActualWidth is the width after rotating the media file if needed.
func (data Data) ActualWidth() int {
if data.Orientation > 4 {
return data.Height
}
return data.Width
}
// ActualHeight is the height after rotating the media file if needed.
func (data Data) ActualHeight() int {
if data.Orientation > 4 {
return data.Width
}
return data.Height
}

View file

@ -122,7 +122,7 @@ func TestExif(t *testing.T) {
assert.Equal(t, "Apple", data.CameraMake) assert.Equal(t, "Apple", data.CameraMake)
assert.Equal(t, "iPhone 7", data.CameraModel) assert.Equal(t, "iPhone 7", data.CameraModel)
assert.Equal(t, 74, data.FocalLength) assert.Equal(t, 74, data.FocalLength)
assert.Equal(t, 6, int(data.Orientation)) assert.Equal(t, 6, data.Orientation)
assert.Equal(t, "Apple", data.LensMake) assert.Equal(t, "Apple", data.LensMake)
assert.Equal(t, "iPhone 7 back camera 3.99mm f/1.8", data.LensModel) assert.Equal(t, "iPhone 7 back camera 3.99mm f/1.8", data.LensModel)
@ -232,4 +232,38 @@ func TestExif(t *testing.T) {
assert.Equal(t, "721", data.All["PixelXDimension"]) assert.Equal(t, "721", data.All["PixelXDimension"])
assert.Equal(t, "332", data.All["PixelYDimension"]) assert.Equal(t, "332", data.All["PixelYDimension"])
}) })
t.Run("orientation.jpg", func(t *testing.T) {
data, err := Exif("testdata/orientation.jpg")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "3264", data.All["PixelXDimension"])
assert.Equal(t, "1836", data.All["PixelYDimension"])
assert.Equal(t, 3264, data.Width)
assert.Equal(t, 1836, data.Height)
assert.Equal(t, 6, data.Orientation) // TODO: Should be 1
if err := data.JSON("testdata/orientation.json", "orientation.jpg"); err != nil {
t.Fatal(err)
}
assert.Equal(t, 326, data.Width)
assert.Equal(t, 184, data.Height)
assert.Equal(t, 1, data.Orientation)
if err := data.JSON("testdata/orientation.json", "foo.jpg"); err != nil {
assert.EqualError(t, err, "meta: original name foo.jpg does not match orientation.jpg (json)")
} else {
t.Error("error expected when providing wrong orginal name")
}
})
t.Run("gopher-preview.jpg", func(t *testing.T) {
_, err := Exif("testdata/gopher-preview.jpg")
assert.EqualError(t, err, "no exif data in gopher-preview.jpg")
})
} }

View file

@ -14,14 +14,14 @@ import (
) )
// JSON parses a json sidecar file (as used by Exiftool) and returns a Data struct. // JSON parses a json sidecar file (as used by Exiftool) and returns a Data struct.
func JSON(fileName string) (data Data, err error) { func JSON(jsonName, originalName string) (data Data, err error) {
err = data.JSON(fileName) err = data.JSON(jsonName, originalName)
return data, err return data, err
} }
// JSON parses a json sidecar file (as used by Exiftool) and returns a Data struct. // JSON parses a json sidecar file (as used by Exiftool) and returns a Data struct.
func (data *Data) JSON(fileName string) (err error) { func (data *Data) JSON(jsonName, originalName string) (err error) {
defer func() { defer func() {
if e := recover(); e != nil { if e := recover(); e != nil {
err = fmt.Errorf("%s (json metadata)", e) err = fmt.Errorf("%s (json metadata)", e)
@ -32,17 +32,17 @@ func (data *Data) JSON(fileName string) (err error) {
data.All = make(map[string]string) data.All = make(map[string]string)
} }
jsonString, err := ioutil.ReadFile(fileName) jsonString, err := ioutil.ReadFile(jsonName)
if err != nil { if err != nil {
log.Warnf("meta: %s", err.Error()) log.Warnf("meta: %s", err.Error())
return fmt.Errorf("can't read %s (json)", txt.Quote(filepath.Base(fileName))) return fmt.Errorf("can't read %s (json)", txt.Quote(filepath.Base(jsonName)))
} }
j := gjson.GetBytes(jsonString, "@flatten|@join") j := gjson.GetBytes(jsonString, "@flatten|@join")
if !j.IsObject() { if !j.IsObject() {
return fmt.Errorf("data is not an object in %s (json)", txt.Quote(filepath.Base(fileName))) return fmt.Errorf("data is not an object in %s (json)", txt.Quote(filepath.Base(jsonName)))
} }
jsonValues := j.Map() jsonValues := j.Map()
@ -51,6 +51,10 @@ func (data *Data) JSON(fileName string) (err error) {
data.All[key] = val.String() data.All[key] = val.String()
} }
if fileName, ok := data.All["FileName"]; ok && fileName != "" && originalName != "" && fileName != originalName {
return fmt.Errorf("meta: original name %s does not match %s (json)", txt.Quote(originalName), txt.Quote(fileName))
}
v := reflect.ValueOf(data).Elem() v := reflect.ValueOf(data).Elem()
// Iterate through all config fields // Iterate through all config fields
@ -132,10 +136,39 @@ func (data *Data) JSON(fileName string) (err error) {
} }
} }
// Fix rotation. if orientation, ok := data.All["Orientation"]; ok && orientation != "" {
if data.Rotation == 90 || data.Rotation == 270 || data.Rotation == -90 { switch orientation {
data.Width, data.Height = data.Height, data.Width case "1", "Horizontal (normal)":
data.Rotation = 0 data.Orientation = 1
case "2":
data.Orientation = 2
case "3", "Rotate 180 CW":
data.Orientation = 3
case "4":
data.Orientation = 4
case "5":
data.Orientation = 5
case "6", "Rotate 90 CW":
data.Orientation = 6
case "7":
data.Orientation = 7
case "8", "Rotate 270 CW":
data.Orientation = 8
}
}
if data.Orientation == 0 {
// Set orientation based on rotation.
switch data.Rotation {
case 0:
data.Orientation = 1
case -180, 180:
data.Orientation = 3
case 90:
data.Orientation = 6
case -90, 270:
data.Orientation = 8
}
} }
// Normalize compression information. // Normalize compression information.

View file

@ -8,7 +8,7 @@ import (
func TestJSON(t *testing.T) { func TestJSON(t *testing.T) {
t.Run("iphone-mov.json", func(t *testing.T) { t.Run("iphone-mov.json", func(t *testing.T) {
data, err := JSON("testdata/iphone-mov.json") data, err := JSON("testdata/iphone-mov.json", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -21,8 +21,11 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "2018-09-08 17:20:14 +0000 UTC", data.TakenAtLocal.String()) assert.Equal(t, "2018-09-08 17:20:14 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2018-09-08 15:20:14 +0000 UTC", data.TakenAt.String()) assert.Equal(t, "2018-09-08 15:20:14 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Berlin", data.TimeZone) assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, 1080, data.Width) assert.Equal(t, 1920, data.Width)
assert.Equal(t, 1920, data.Height) assert.Equal(t, 1080, data.Height)
assert.Equal(t, 1080, data.ActualWidth())
assert.Equal(t, 1920, data.ActualHeight())
assert.Equal(t, 6, data.Orientation)
assert.Equal(t, float32(52.4587), data.Lat) assert.Equal(t, float32(52.4587), data.Lat)
assert.Equal(t, float32(13.4593), data.Lng) assert.Equal(t, float32(13.4593), data.Lng)
assert.Equal(t, "Apple", data.CameraMake) assert.Equal(t, "Apple", data.CameraMake)
@ -31,7 +34,7 @@ func TestJSON(t *testing.T) {
}) })
t.Run("gopher-telegram.json", func(t *testing.T) { t.Run("gopher-telegram.json", func(t *testing.T) {
data, err := JSON("testdata/gopher-telegram.json") data, err := JSON("testdata/gopher-telegram.json", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -46,6 +49,9 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "", data.TimeZone) assert.Equal(t, "", data.TimeZone)
assert.Equal(t, 270, data.Width) assert.Equal(t, 270, data.Width)
assert.Equal(t, 480, data.Height) assert.Equal(t, 480, data.Height)
assert.Equal(t, 270, data.ActualWidth())
assert.Equal(t, 480, data.ActualHeight())
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, float32(0), data.Lat) assert.Equal(t, float32(0), data.Lat)
assert.Equal(t, float32(0), data.Lng) assert.Equal(t, float32(0), data.Lng)
assert.Equal(t, "", data.CameraMake) assert.Equal(t, "", data.CameraMake)
@ -54,7 +60,7 @@ func TestJSON(t *testing.T) {
}) })
t.Run("gopher-original.json", func(t *testing.T) { t.Run("gopher-original.json", func(t *testing.T) {
data, err := JSON("testdata/gopher-original.json") data, err := JSON("testdata/gopher-original.json", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -67,8 +73,12 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "2020-05-11 14:16:48 +0000 UTC", data.TakenAtLocal.String()) assert.Equal(t, "2020-05-11 14:16:48 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2020-05-11 12:16:48 +0000 UTC", data.TakenAt.String()) assert.Equal(t, "2020-05-11 12:16:48 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Berlin", data.TimeZone) assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, 1080, data.Width) assert.Equal(t, 1920, data.Width)
assert.Equal(t, 1920, data.Height) assert.Equal(t, 1080, data.Height)
assert.Equal(t, 1080, data.ActualWidth())
assert.Equal(t, 1920, data.ActualHeight())
assert.Equal(t, float32(0.5625), data.AspectRatio())
assert.Equal(t, 6, data.Orientation)
assert.Equal(t, float32(52.4596), data.Lat) assert.Equal(t, float32(52.4596), data.Lat)
assert.Equal(t, float32(13.3218), data.Lng) assert.Equal(t, float32(13.3218), data.Lng)
assert.Equal(t, "", data.CameraMake) assert.Equal(t, "", data.CameraMake)
@ -77,7 +87,7 @@ func TestJSON(t *testing.T) {
}) })
t.Run("berlin-landscape.json", func(t *testing.T) { t.Run("berlin-landscape.json", func(t *testing.T) {
data, err := JSON("testdata/berlin-landscape.json") data, err := JSON("testdata/berlin-landscape.json", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -92,6 +102,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "Europe/Berlin", data.TimeZone) assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, 1920, data.Width) assert.Equal(t, 1920, data.Width)
assert.Equal(t, 1080, data.Height) assert.Equal(t, 1080, data.Height)
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, float32(52.4649), data.Lat) assert.Equal(t, float32(52.4649), data.Lat)
assert.Equal(t, float32(13.3148), data.Lng) assert.Equal(t, float32(13.3148), data.Lng)
assert.Equal(t, "", data.CameraMake) assert.Equal(t, "", data.CameraMake)
@ -100,7 +111,7 @@ func TestJSON(t *testing.T) {
}) })
t.Run("mp4.json", func(t *testing.T) { t.Run("mp4.json", func(t *testing.T) {
data, err := JSON("testdata/mp4.json") data, err := JSON("testdata/mp4.json", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -113,6 +124,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "2019-11-23 13:51:49 +0000 UTC", data.TakenAtLocal.String()) assert.Equal(t, "2019-11-23 13:51:49 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, 848, data.Width) assert.Equal(t, 848, data.Width)
assert.Equal(t, 480, data.Height) assert.Equal(t, 480, data.Height)
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, "", data.Copyright) assert.Equal(t, "", data.Copyright)
assert.Equal(t, "", data.CameraMake) assert.Equal(t, "", data.CameraMake)
assert.Equal(t, "", data.CameraModel) assert.Equal(t, "", data.CameraModel)
@ -120,7 +132,7 @@ func TestJSON(t *testing.T) {
}) })
t.Run("photoshop.json", func(t *testing.T) { t.Run("photoshop.json", func(t *testing.T) {
data, err := JSON("testdata/photoshop.json") data, err := JSON("testdata/photoshop.json", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -142,10 +154,11 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "HUAWEI", data.CameraMake) assert.Equal(t, "HUAWEI", data.CameraMake)
assert.Equal(t, "ELE-L29", data.CameraModel) assert.Equal(t, "ELE-L29", data.CameraModel)
assert.Equal(t, "HUAWEI P30 Rear Main Camera", data.LensModel) assert.Equal(t, "HUAWEI P30 Rear Main Camera", data.LensModel)
assert.Equal(t, 1, data.Orientation)
}) })
t.Run("canon_eos_6d.json", func(t *testing.T) { t.Run("canon_eos_6d.json", func(t *testing.T) {
data, err := JSON("testdata/canon_eos_6d.json") data, err := JSON("testdata/canon_eos_6d.json", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -161,10 +174,11 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "Canon", data.CameraMake) assert.Equal(t, "Canon", data.CameraMake)
assert.Equal(t, "Canon EOS 6D", data.CameraModel) assert.Equal(t, "Canon EOS 6D", data.CameraModel)
assert.Equal(t, "EF24-105mm f/4L IS USM", data.LensModel) assert.Equal(t, "EF24-105mm f/4L IS USM", data.LensModel)
assert.Equal(t, 1, data.Orientation)
}) })
t.Run("gps-2000.json", func(t *testing.T) { t.Run("gps-2000.json", func(t *testing.T) {
data, err := JSON("testdata/gps-2000.json") data, err := JSON("testdata/gps-2000.json", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -181,10 +195,11 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "", data.CameraModel) assert.Equal(t, "", data.CameraModel)
assert.Equal(t, "", data.LensMake) assert.Equal(t, "", data.LensMake)
assert.Equal(t, "", data.LensModel) assert.Equal(t, "", data.LensModel)
assert.Equal(t, 1, data.Orientation)
}) })
t.Run("ladybug.json", func(t *testing.T) { t.Run("ladybug.json", func(t *testing.T) {
data, err := JSON("testdata/ladybug.json") data, err := JSON("testdata/ladybug.json", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -201,10 +216,11 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "", data.CameraModel) assert.Equal(t, "", data.CameraModel)
assert.Equal(t, "", data.LensMake) assert.Equal(t, "", data.LensMake)
assert.Equal(t, "", data.LensModel) assert.Equal(t, "", data.LensModel)
assert.Equal(t, 1, data.Orientation)
}) })
t.Run("iphone_7.json", func(t *testing.T) { t.Run("iphone_7.json", func(t *testing.T) {
data, err := JSON("testdata/iphone_7.json") data, err := JSON("testdata/iphone_7.json", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -217,6 +233,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "", data.Artist) assert.Equal(t, "", data.Artist)
assert.Equal(t, "", data.Description) assert.Equal(t, "", data.Description)
assert.Equal(t, "", data.Copyright) assert.Equal(t, "", data.Copyright)
assert.Equal(t, 6, data.Orientation)
assert.Equal(t, "Apple", data.CameraMake) assert.Equal(t, "Apple", data.CameraMake)
assert.Equal(t, "iPhone 7", data.CameraModel) assert.Equal(t, "iPhone 7", data.CameraModel)
assert.Equal(t, "Apple", data.LensMake) assert.Equal(t, "Apple", data.LensMake)
@ -224,7 +241,7 @@ func TestJSON(t *testing.T) {
}) })
t.Run("uuid-original.json", func(t *testing.T) { t.Run("uuid-original.json", func(t *testing.T) {
data, err := JSON("testdata/uuid-original.json") data, err := JSON("testdata/uuid-original.json", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -241,6 +258,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "Europe/Berlin", data.TimeZone) assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, 3024, data.Width) assert.Equal(t, 3024, data.Width)
assert.Equal(t, 4032, data.Height) assert.Equal(t, 4032, data.Height)
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, float32(48.300003), data.Lat) assert.Equal(t, float32(48.300003), data.Lat)
assert.Equal(t, float32(8.929067), data.Lng) assert.Equal(t, float32(8.929067), data.Lng)
assert.Equal(t, "Apple", data.CameraMake) assert.Equal(t, "Apple", data.CameraMake)
@ -249,7 +267,7 @@ func TestJSON(t *testing.T) {
}) })
t.Run("uuid-copy.json", func(t *testing.T) { t.Run("uuid-copy.json", func(t *testing.T) {
data, err := JSON("testdata/uuid-copy.json") data, err := JSON("testdata/uuid-copy.json", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -266,6 +284,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "Europe/Berlin", data.TimeZone) assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, 1024, data.Width) assert.Equal(t, 1024, data.Width)
assert.Equal(t, 1365, data.Height) assert.Equal(t, 1365, data.Height)
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, float32(48.300003), data.Lat) assert.Equal(t, float32(48.300003), data.Lat)
assert.Equal(t, float32(8.929067), data.Lng) assert.Equal(t, float32(8.929067), data.Lng)
assert.Equal(t, "Apple", data.CameraMake) assert.Equal(t, "Apple", data.CameraMake)
@ -274,7 +293,7 @@ func TestJSON(t *testing.T) {
}) })
t.Run("uuid-imagemagick.json", func(t *testing.T) { t.Run("uuid-imagemagick.json", func(t *testing.T) {
data, err := JSON("testdata/uuid-imagemagick.json") data, err := JSON("testdata/uuid-imagemagick.json", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -291,10 +310,23 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "Europe/Berlin", data.TimeZone) assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, 1125, data.Width) assert.Equal(t, 1125, data.Width)
assert.Equal(t, 1500, data.Height) assert.Equal(t, 1500, data.Height)
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, float32(48.300003), data.Lat) assert.Equal(t, float32(48.300003), data.Lat)
assert.Equal(t, float32(8.929067), data.Lng) assert.Equal(t, float32(8.929067), data.Lng)
assert.Equal(t, "Apple", data.CameraMake) assert.Equal(t, "Apple", data.CameraMake)
assert.Equal(t, "iPhone SE", data.CameraModel) assert.Equal(t, "iPhone SE", data.CameraModel)
assert.Equal(t, "iPhone SE back camera 4.15mm f/2.2", data.LensModel) assert.Equal(t, "iPhone SE back camera 4.15mm f/2.2", data.LensModel)
}) })
t.Run("orientation.json", func(t *testing.T) {
data, err := JSON("testdata/orientation.json", "")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, 326, data.Width)
assert.Equal(t, 184, data.Height)
assert.Equal(t, 1, data.Orientation)
})
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
internal/meta/testdata/orientation.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

91
internal/meta/testdata/orientation.json vendored Normal file
View file

@ -0,0 +1,91 @@
[{
"SourceFile": "orientation.jpg",
"ExifToolVersion": 10.80,
"FileName": "orientation.jpg",
"Directory": ".",
"FileSize": "45 kB",
"FileModifyDate": "2020:06:04 09:20:43+00:00",
"FileAccessDate": "2020:06:04 09:20:45+00:00",
"FileInodeChangeDate": "2020:06:04 09:20:43+00:00",
"FilePermissions": "rw-r--r--",
"FileType": "JPEG",
"FileTypeExtension": "jpg",
"MIMEType": "image/jpeg",
"JFIFVersion": 1.01,
"ExifByteOrder": "Little-endian (Intel, II)",
"Make": "samsung",
"Model": "SM-G900F",
"Orientation": "Horizontal (normal)",
"XResolution": 72,
"YResolution": 72,
"ResolutionUnit": "inches",
"Software": "G900FXXS1CQD1",
"ModifyDate": "2018:10:03 16:56:19",
"YCbCrPositioning": "Centered",
"ExposureTime": "1/2376",
"FNumber": 2.2,
"ExposureProgram": "Program AE",
"ISO": 40,
"ExifVersion": "0220",
"DateTimeOriginal": "2018:10:03 16:56:19",
"CreateDate": "2018:10:03 16:56:19",
"ComponentsConfiguration": "Y, Cb, Cr, -",
"ShutterSpeedValue": "1/2369",
"ApertureValue": 2.2,
"BrightnessValue": 9.92,
"ExposureCompensation": 0,
"MaxApertureValue": 2.2,
"MeteringMode": "Center-weighted average",
"LightSource": "Unknown",
"Flash": "Fired",
"FocalLength": "4.8 mm",
"UserComment": "\n",
"SubSecTime": 414,
"SubSecTimeOriginal": 414,
"SubSecTimeDigitized": 414,
"FlashpixVersion": "0100",
"ColorSpace": "sRGB",
"ExifImageWidth": 3264,
"ExifImageHeight": 1836,
"InteropIndex": "R98 - DCF basic file (sRGB)",
"InteropVersion": "0100",
"SensingMethod": "One-chip color area",
"SceneType": "Directly photographed",
"ExposureMode": "Auto",
"WhiteBalance": "Auto",
"FocalLengthIn35mmFormat": "31 mm",
"SceneCaptureType": "Standard",
"ImageUniqueID": "F16QLHF01VB",
"GPSVersionID": "2.2.0.0",
"GPSLatitudeRef": "North",
"GPSLongitudeRef": "East",
"Compression": "JPEG (old-style)",
"ThumbnailOffset": 3426,
"ThumbnailLength": 12320,
"XMPToolkit": "Image::ExifTool 10.80",
"Subject": "Nice",
"Title": "Côte d'Azur 3-9 octobre 2018 - Nice",
"ImageWidth": 326,
"ImageHeight": 184,
"EncodingProcess": "Baseline DCT, Huffman coding",
"BitsPerSample": 8,
"ColorComponents": 3,
"YCbCrSubSampling": "YCbCr4:2:0 (2 2)",
"Aperture": 2.2,
"GPSLatitude": "43 deg 41' 45.00\" N",
"GPSLongitude": "7 deg 16' 17.00\" E",
"GPSPosition": "43 deg 41' 45.00\" N, 7 deg 16' 17.00\" E",
"ImageSize": "326x184",
"Megapixels": 0.060,
"ScaleFactor35efl": 6.5,
"ShutterSpeed": "1/2376",
"SubSecCreateDate": "2018:10:03 16:56:19.414",
"SubSecDateTimeOriginal": "2018:10:03 16:56:19.414",
"SubSecModifyDate": "2018:10:03 16:56:19.414",
"ThumbnailImage": "(Binary data 12320 bytes, use -b option to extract)",
"CircleOfConfusion": "0.005 mm",
"FOV": "60.3 deg",
"FocalLength35efl": "4.8 mm (35 mm equivalent: 31.0 mm)",
"HyperfocalDistance": "2.25 m",
"LightValue": 14.8
}]

View file

@ -226,8 +226,8 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
metaData = m.MetaData() metaData = m.MetaData()
file.FileCodec = metaData.Codec file.FileCodec = metaData.Codec
file.FileWidth = metaData.Width file.FileWidth = metaData.ActualWidth()
file.FileHeight = metaData.Height file.FileHeight = metaData.ActualHeight()
file.FileDuration = metaData.Duration file.FileDuration = metaData.Duration
file.FileAspectRatio = metaData.AspectRatio() file.FileAspectRatio = metaData.AspectRatio()
file.FilePortrait = metaData.Portrait() file.FilePortrait = metaData.Portrait()

View file

@ -309,6 +309,11 @@ func (m *MediaFile) FileName() string {
return m.fileName return m.fileName
} }
// BaseName returns the filename without path.
func (m *MediaFile) BaseName() string {
return filepath.Base(m.fileName)
}
// SetFileName sets the filename to the given string. // SetFileName sets the filename to the given string.
func (m *MediaFile) SetFileName(fileName string) { func (m *MediaFile) SetFileName(fileName string) {
m.fileName = fileName m.fileName = fileName
@ -620,7 +625,7 @@ func (m *MediaFile) HasJson() bool {
} }
func (m *MediaFile) decodeDimensions() error { func (m *MediaFile) decodeDimensions() error {
if !m.IsPhoto() { if !m.IsMedia() {
return fmt.Errorf("not a photo: %s", m.FileName()) return fmt.Errorf("not a photo: %s", m.FileName())
} }
@ -665,7 +670,7 @@ func (m *MediaFile) decodeDimensions() error {
// Width return the width dimension of a MediaFile. // Width return the width dimension of a MediaFile.
func (m *MediaFile) Width() int { func (m *MediaFile) Width() int {
if !m.IsPhoto() { if !m.IsMedia() {
return 0 return 0
} }
@ -680,7 +685,7 @@ func (m *MediaFile) Width() int {
// Height returns the height dimension of a MediaFile. // Height returns the height dimension of a MediaFile.
func (m *MediaFile) Height() int { func (m *MediaFile) Height() int {
if !m.IsPhoto() { if !m.IsMedia() {
return 0 return 0
} }

View file

@ -22,8 +22,8 @@ func (m *MediaFile) MetaData() (result meta.Data) {
if jsonFile := fs.TypeJson.FindSub(m.FileName(), fs.HiddenPath, false); jsonFile == "" { if jsonFile := fs.TypeJson.FindSub(m.FileName(), fs.HiddenPath, false); jsonFile == "" {
log.Debugf("mediafile: no json sidecar file found for %s", txt.Quote(filepath.Base(m.FileName()))) log.Debugf("mediafile: no json sidecar file found for %s", txt.Quote(filepath.Base(m.FileName())))
} else if jsonErr := m.metaData.JSON(jsonFile); jsonErr != nil { } else if jsonErr := m.metaData.JSON(jsonFile, m.BaseName()); jsonErr != nil {
log.Warn(jsonErr) log.Debug(jsonErr)
} else { } else {
err = nil err = nil
} }

View file

@ -138,10 +138,23 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) {
s = s.Where("photos.photo_name LIKE ?", strings.ReplaceAll(f.Name, "*", "%")) s = s.Where("photos.photo_name LIKE ?", strings.ReplaceAll(f.Name, "*", "%"))
} }
if f.Review { // Filter by status.
s = s.Where("photos.photo_quality < 3") if f.Archived {
} else if f.Quality != 0 { s = s.Where("photos.deleted_at IS NOT NULL")
s = s.Where("photos.photo_quality >= ?", f.Quality) } else {
s = s.Where("photos.deleted_at IS NULL")
if f.Private {
s = s.Where("photos.photo_private = 1")
} else if f.Public {
s = s.Where("photos.photo_private = 0")
}
if f.Review {
s = s.Where("photos.photo_quality < 3")
} else if f.Quality != 0 && f.Private == false {
s = s.Where("photos.photo_quality >= ?", f.Quality)
}
} }
if f.Favorite { if f.Favorite {

View file

@ -126,7 +126,10 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
} }
// Filter by status. // Filter by status.
if f.Archived { if f.Hidden {
s = s.Where("photos.photo_quality = -1")
s = s.Where("photos.deleted_at IS NULL")
} else if f.Archived {
s = s.Where("photos.deleted_at IS NOT NULL") s = s.Where("photos.deleted_at IS NOT NULL")
} else { } else {
s = s.Where("photos.deleted_at IS NULL") s = s.Where("photos.deleted_at IS NULL")